malmeloo / FindMy.py

๐Ÿ + ๐ŸŽฏ + ๐Ÿ = Everything you need to work with Apple's FindMy network!
http://docs.mikealmel.ooo/FindMy.py/
MIT License
59 stars 7 forks source link

Add MAP to HomeAssistant #37

Open wes1993 opened 1 month ago

wes1993 commented 1 month ago

Hello @malmeloo, I have created a script that can export all the location then send a HTML file to HomeAssistant: image

Here is the code, If you want try :-D

"""
Example showing how to fetch locations of an AirTag, or any other FindMy accessory.
"""
from __future__ import annotations

import logging
import sys
from pathlib import Path

from _login import get_account_sync

from findmy import FindMyAccessory
from findmy.reports import RemoteAnisetteProvider

######### CSV and MAP CREATION
import csv
from gmplot import gmplot
from math import radians, cos, sin, asin, sqrt
from dateutil import parser as dtparser

######### Send to HomeAssistant
import paramiko
from paramiko import SSHClient
from scp import SCPClient

########################################### Configuration ###########################################
# URL to (public or local) anisette server
ANISETTE_SERVER = "http://localhost:6969"

# Name of the Airtag, used also for naming CSV file and HTML map File
person = "PersonName"

# Map Configuration
# Home LAT AND LON, See Google Maps or HA to find this Info
LAT = 41.80
LON = 12.62

#ZOOM level, where 0 is fully zoomed out. Defaults to 13.
ZOOM = 12

# GMAPS ApiKey https://cloud.google.com/maps-platform/ click Get Started. Choose Maps and follow the instructions. Set restrictions for the API Key (Click on Maps Javascript API > Credentials > Your api Key > API restrictions and select Maps Javascript API).
apikey = ""

# Create PLOT, Values are True or False
CreatePlot = True
# Color of the PolyLines, Can be hex (โ€˜#00FFFFโ€™), named (โ€˜cyanโ€™), or matplotlib-like (โ€˜cโ€™), see here https://github.com/gmplot/gmplot/blob/master/gmplot/color.py and ColorCode.pdf: 
PColor = 'cornflowerblue'
#Width of the polyline, in pixels. Defaults to 1.
PWidth = 1

# Marker COLOR Can be hex (โ€˜#00FFFFโ€™), named (โ€˜cyanโ€™), or matplotlib-like (โ€˜cโ€™) see here https://github.com/gmplot/gmplot/blob/master/gmplot/color.py and ColorCode.pdf: 
# All Markers
#AMColor = 'red'
AMColor = '#FFFFFF'
# Keep Disabled until Fix Found, If Enabled the Last Location not evident
AMColorDisabled = False
# Last Location Marker Color and Text Color see here https://github.com/gmplot/gmplot/blob/master/gmplot/color.py and ColorCode.pdf: 
#LMColor = 'chartreuse'
LMColor = '#00FF00'
MARKERLABEL = False
# Number of last marker, for print all marker set to False
NMARKER = False
# Dark Mode for the MAP, Values are True or False
DARKMODE = True

# Force Map Creation Only and Send To HA if Send to HA is Enables, Values are True or False, Should be FALSE
FORCEMAP = False

# Radius 0.05 means 50 MT from older location, so locations under 100MT from before location will be ignored
radius = 0.05

#HomeAssistant Connection Details to send MAP HTML FILE
#Send MAP to HA Instance, Values are True or False
SENDTOHA = True
HAHostName=''
HAPort = ''
HAUsername=''
HAPassword=''
########################################### END Configuration ###########################################

logging.basicConfig(level=logging.INFO)

def openfiles():
    global lasthistory, histidx
    with open('data/lasthistory.txt','r', encoding="utf8") as f:
      lasthistory = f.readline()
      if len(lasthistory) == 0:
        histidx = 0

def haversine(lat1, lon1, lat2, lon2):
    """
    Calculate the great circle distance between two points 
    on the earth (specified in decimal degrees)
    https://stackoverflow.com/questions/4913349/haversine-formula-in-python-bearing-and-distance-between-two-gps-points
    """
    # convert decimal degrees to radians 
    lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])

    # haversine formula 
    dlon = lon2 - lon1 
    dlat = lat2 - lat1 
    a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
    c = 2 * asin(sqrt(a)) 
    r = 6371 # Radius of earth in kilometers. Use 3956 for miles
    return c * r

def main(plist_path: str) -> int:
    global lasthistory, histidx
    # Step 0: create an accessory key generator
    with Path(plist_path).open("rb") as f:
        airtag = FindMyAccessory.from_plist(f)

    # Step 1: log into an Apple account
    print("Logging into account")
    anisette = RemoteAnisetteProvider(ANISETTE_SERVER)
    acc = get_account_sync(anisette)

    # step 2: fetch reports!
    print("Fetching reports")
    reports = acc.fetch_last_reports(airtag)

    # step 3: Order reports, last at the end
    sorted_reports = sorted(reports, key=lambda x: x.timestamp)

    # step 4: Find the index of the last printed report
    for index, report in enumerate(sorted_reports):
        if (repr(str(report.timestamp)) == repr(str(lasthistory.strip()))):
            histidx = index + 1

    # step 5: print 'em and save to CSV
    print()
    print("Location reports:")
    print("Total Location Report:", len(sorted_reports), "Missing Location Report to Extract:", len(sorted_reports) - histidx)

    if (len(sorted_reports) - histidx > 0):
        for sorted_report in sorted_reports[histidx:]:
            print(f" - {sorted_report}")
            lastreport = sorted_report
            #print(dir(sorted_report))

            latitude = sorted_report.latitude
            longitude = sorted_report.longitude
            date = sorted_report.timestamp

            values = Path("data/"+person+"_gps.csv")
            if values.is_file():
                with open('data/'+person+'_gps.csv', 'a', newline='') as newFile:
                    newFileWriter = csv.writer(newFile)
                    newFileWriter.writerow([latitude,longitude,date])

            else:
                with open('data/'+person+'_gps.csv','w', newline='') as newFile:
                    newFileWriter = csv.writer(newFile)
                    newFileWriter.writerow(['latitude','longitude','date'])
                    newFileWriter.writerow([latitude,longitude,date])

    print("Location Report Extracted: ", len(sorted_reports) - histidx)
    print("No more Location Report Fetched")

    # step 6: Save the new printed report timestamp in the DB
    try:
        lastreport.timestamp
    except: 
        pass
    else: 
        with open('data/lasthistory.txt', 'w') as f:
            f.write(str(lastreport.timestamp))

    # step 7: If there are new location report, create a NEW MAP
        createmap()
    return 0

def createmap():
    print ("Creating HTML MAP")

#    map_styles = [
#        {
#            'featureType': 'all',
#            'stylers': [
#                {'saturation': -80},
#                {'lightness': 30},
#            ]
#        }
#    ]

# DARK MODE Style Settings For MAP
    map_styles = [
        { 'elementType': 'geometry', 'stylers': [{ 'color': "#242f3e" }] },
        { 'elementType': "labels.text.stroke", 'stylers': [{ 'color': "#242f3e" }] },
        { 'elementType': "labels.text.fill", 'stylers': [{ 'color': "#746855" }] },
        {
            'featureType': "administrative.locality",
            'elementType': "labels.text.fill",
            'stylers': [{ 'color': "#d59563" }],
        },
        {
            'featureType': "poi",
            'elementType': "labels.text.fill",
            'stylers': [{ 'color': "#d59563" }],
        },
        {
            'featureType': "poi.park",
            'elementType': "geometry",
            'stylers': [{ 'color': "#263c3f" }],
        },
        {
            'featureType': "poi.park",
            'elementType': "labels.text.fill",
            'stylers': [{ 'color': "#6b9a76" }],
        },
        {
            'featureType': "road",
            'elementType': "geometry",
            'stylers': [{ 'color': "#38414e" }],
        },
        {
            'featureType': "road",
            'elementType': "geometry.stroke",
            'stylers': [{ 'color': "#212a37" }],
        },
        {
            'featureType': "road",
            'elementType': "labels.text.fill",
            'stylers': [{ 'color': "#9ca5b3" }],
        },
        {
            'featureType': "road.highway",
            'elementType': "geometry",
            'stylers': [{ 'color': "#746855" }],
        },
        {
            'featureType': "road.highway",
            'elementType': "geometry.stroke",
            'stylers': [{ 'color': "#1f2835" }],
        },
        {
            'featureType': "road.highway",
            'elementType': "labels.text.fill",
            'stylers': [{ 'color': "#f3d19c" }],
        },
        {
            'featureType': "transit",
            'elementType': "geometry",
            'stylers': [{ 'color': "#2f3948" }],
        },
        {
            'featureType': "transit.station",
            'elementType': "labels.text.fill",
            'stylers': [{ 'color': "#d59563" }],
        },
        {
            'featureType': "water",
            'elementType': "geometry",
            'stylers': [{ 'color': "#17263c" }],
        },
        {
            'featureType': "water",
            'elementType': "labels.text.fill",
            'stylers': [{ 'color': "#515c6d" }],
        },
        {
            'featureType': "water",
            'elementType': "labels.text.stroke",
            'stylers': [{ 'color': "#17263c" }],
        },
    ]

# DARK MODE Style Settings For MAP

    if DARKMODE == True:
        gmap = gmplot.GoogleMapPlotter(LAT, LON, ZOOM, apikey=apikey, map_styles=map_styles, scale_control=True)
    else:
        gmap = gmplot.GoogleMapPlotter(LAT, LON, ZOOM, apikey=apikey, scale_control=True)

    gps = []
    locmarker = []

    filecsv = open('data/'+person+'_gps.csv')
    csvnumline = len(filecsv.readlines())

    with open('data/'+person+'_gps.csv') as csv_file:
        csv_reader = csv.reader(csv_file, delimiter=',')

        previous_row = []
        for line, row in enumerate(csv_reader):
            if line == 0:
                pass
            else:
                if previous_row:
                    prev_lat = float(previous_row[0])
                    prev_lon = float(previous_row[1])
                    lat = float(row[0])
                    lon = float(row[1])
                    a = haversine(float(prev_lat), float(prev_lon), float(lat), float(lon))
                    #print('Distance (km) : ', a)
                    if a <= radius:
                        continue
                    else:
                        gps.append((lat,lon))
                        locmarker.append((float(row[0]),float(row[1]), dtparser.parse(row[2]).strftime("%d/%m/%Y %H:%M:%S")))
                        previous_row = row

                else:
                    gps.append((float(row[0]),float(row[1])))
                    locmarker.append((float(row[0]),float(row[1]), dtparser.parse(row[2]).strftime("%d/%m/%Y %H:%M:%S")))
                    previous_row = row

    # Check for Marker
    for idx, row in enumerate(locmarker):
        if idx == (len(locmarker) - 1):
            if (MARKERLABEL == True):
                gmap.marker(row[0], row[1], title=row[2], info_window="<h1><p style='color:" + LMColor + "'>" + row[2] + "</p></h1>", color=LMColor, label=idx)
            else:
                gmap.marker(row[0], row[1], title=row[2], info_window="<h1><p style='color:" + LMColor + "'>" + row[2] + "</p></h1>", color=LMColor)

        else:
            if (NMARKER == False):
                if AMColorDisabled == True:
                    gmap.marker(row[0], row[1], title=str(row[2]), info_window="<h1>" + row[2] + "</h1>", label=idx)
                else:
                    gmap.marker(row[0], row[1], title=str(row[2]), info_window="<h1>" + row[2] + "</h1>", label=idx, color=AMColor)
            else:
                if (idx >= (len(locmarker) - NMARKER) and idx < (len(locmarker) - 1)):
                    if AMColorDisabled == True:
                        gmap.marker(row[0], row[1], title=str(row[2]), info_window="<h1>" + row[2] + "</h1>", label=idx)
                    else:
                        gmap.marker(row[0], row[1], title=str(row[2]), info_window="<h1>" + row[2] + "</h1>", label=idx, color=AMColor)

    # Polygon
    lats, lons = zip(*gps)

    if CreatePlot == True:    
        gmap.plot(lats, lons, color=PColor, edge_width=PWidth)

    gmap.draw("data/"+person+"_gps.html")

    if SENDTOHA == True:
        sendtohomeassistant()

def sendtohomeassistant():
    print("Send MAP To HomeAssistant Instance, Under PATH /config/www/findmy/"+person+"_gps.html" )
    ssh = SSHClient()
    #ssh.load_system_host_keys()
    ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    ssh.connect(hostname = HAHostName, port = HAPort, username = HAUsername, password = HAPassword)

    # SCPCLient takes a paramiko transport as its only argument
    scp = SCPClient(ssh.get_transport())

    scp.put("data/"+person+"_gps.html", "/config/www/findmy/"+person+"_gps.html")
#    scp.get('file_path_on_remote_machine', 'file_path_on_local_machine')
    scp.close()

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} <path to accessory plist>", file=sys.stderr)
        print(file=sys.stderr)
        print("The plist file should be dumped from MacOS's FindMy app.", file=sys.stderr)
        sys.exit(1)
    if FORCEMAP == True:
        createmap()
    else:
        openfiles()
        sys.exit(main(sys.argv[1]))
wes1993 commented 1 month ago

ColorCode.pdf

malmeloo commented 1 month ago

That's so cool! I actually don't even have a functional tag right now, so I won't be able to try this myself (yet) ๐Ÿ˜…. If you put up the code somewhere I will link to it in the readme, there are probably more people who are also interested in this.

Informatic commented 1 month ago

It should be fairly easy to create a "proper" custom device tracker component based on FindMy.py - that would also have an added benefit of device location becoming usable in automations and all. I may look into it sometime in the near future.

malmeloo commented 1 month ago

I have the foundations of a custom integration working already with a limited login flow, haven't had much time to work on it again but I can publish my progress today or tomorrow.

guniv commented 1 month ago

Just chiming in to say a custom integration for AirTags would be really great.

malmeloo commented 1 month ago

Sorry for the delay, but I just published the integration to a separate repository: https://github.com/malmeloo/hass-FindMy

The code is from back in February, so I hope it still works with the latest version of Home Assistant. IIRC it's able to log into an account + 2FA but not much more than that, but it should be an OK foundation to start with.