Standort meines Tesla via Tessie und Home Assistant teilen

Vor einiger Zeit habe ich einen fantastischen Roadtrip „erlebt“. Es wäre schön gewesen, hätte ich meinen Standort teilen können, und zwar so, dass mein Telefon nicht gleich zu einem Glühwürfel mutiert.

Da ich – wie einige Tesla-Fahrer – Tessie nutze und liebe, z.B. um unter anderem die Kosten für mein Fahrzeug zu tracken, liegt die Idee nahe, dessen API zu nutzen, um die Positionsdaten damit zu teilen, ohne auf die rechen- und stromintensive Google-Maps-Funktion des Telefons zu setzen.

Ich brauche lediglich eine Webseite, welche – temporär – den Standort meines Tesla darstellt, und nach Ablauf eines Timers die Anzeige meines Standorts wieder deaktiviert. Da Tessie die Funktion (im Moment) von Haus aus nicht bietet, und ich den Home Assistant samt Tessie Integration nutze, habe ich den Spieß einfach mal umgedreht.

Bei mir gibt es im Home-Assistant-Dashboard für mein Fahrzeug jetzt so einen Abschnitt:

Dazu habe ich eine simple Webseite, welche auf jedem Telefon oder Browser für die Zeit des Timers darstellbar ist:

und den Standort meines Fahrzeugs sowie die Dauer bis zur Ankunft anzeigt, sofern ich das Ziel im Navigationssystem hinterlegt habe.

Was brauchst Du dazu?

  • Home Assistant (mit Fernzugriff, falls du es von unterwegs steuern willst)
  • Tessie Integration (oder alternativ die Tesla Custom Component) für die Fahrzeugdaten
  • Eigener Webspace mit PHP-Unterstützung (ein einfacher Shared-Hosting-Tarif reicht völlig)

Nachfolgend kommt ein bisschen Beispielcode, damit Du das ebenfalls hinbekommst und nicht über dieselben Fallstricke stolperst, wie ich zuvor.

Anmerkung zum Datenschutz: Das Ding dient dem, Deinen Standort anzuzeigen. Ich gehe davon aus, dass Du das so willst. Außerdem bin ich auf keinen Fall frei von Fehlern. Du solltest nachfolgend ein starkes, sehr langes Geheimnis wählen, was nicht schnell über Bruteforce erraten werden kann. Ich habe eingebaut, was auf einem regulären Webspace eben möglich ist. Wenn Du Verbesserungsvorschläge hast, gerne her damit.

Webspace vorbereiten

Ich lege einen Ordner an, dessen URL ich später teilen möchte. In diesem Ordner benötige ich ein PHP-Script, welches die Daten von Home Assistant annimmt.

Ich erstelle die Datei „update.php“:

<?php
// update.php - Empfängt die GPS-Daten von Home Assistant
$raw_input = file_get_contents('php://input');
$data = json_decode($raw_input, true);

if (!is_array($data)) {
    http_response_code(400);
    die('Ungültiges Datenformat');
}

$geheimnis = 'DEIN_PASSWORT_HIER';
$übermitteltes_geheimnis = isset($data['secret']) ? (string)$data['secret'] : '';

// Sofortiger Abbruch ohne sleep(), um die PHP-Worker nicht zu blockieren (Schutz vor DoS)
if (!hash_equals($geheimnis, $übermitteltes_geheimnis)) {
    http_response_code(401);
    die('Zugriff verweigert!');
}

// Reine Typ-Sicherung (Sanitization), KEIN HTML-Escaping hier!
$clean_data = [
    'status'      => substr((string)($data['status'] ?? ''), 0, 20),
    'lat'         => (float)($data['lat'] ?? 0),
    'lon'         => (float)($data['lon'] ?? 0),
    'dest'        => substr((string)($data['dest'] ?? ''), 0, 100),
    'eta'         => (int)($data['eta'] ?? 0),
    'soc_arrival' => (int)($data['soc_arrival'] ?? 0)
];

// Race-Condition Schutz beim Speichern
file_put_contents('data.json', json_encode($clean_data), LOCK_EX);

echo "OK";
?>

In demselben Ordner erstelle ich ebenfalls die Datei „index.html“, welche später die Positionsinformation von meinem Auto für meine Freunde und mein Familie darstellt:

<!DOCTYPE html>
<html lang="de">
<head>
    <title>Live Standort von Moneypenny</title>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
    <style>
        html { height: 100%; }
        body { 
            margin: 0; padding: 5px; box-sizing: border-box; 
            height: 100%; height: 100dvh; background: #222; 
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 
        }
        #container {
            display: flex; flex-direction: column; height: 100%; 
            background: #f8f9fa; border-radius: 6px; overflow: hidden;
            box-shadow: 0 4px 15px rgba(0,0,0,0.3);
        }
        #header {
            background: #ffffff; padding: 15px; text-align: center;
            border-bottom: 1px solid #ddd; font-weight: 600; font-size: 1.2rem; color: #333;
        }
        #map-wrapper { flex: 1; position: relative; }
        #map { height: 100%; width: 100%; display: none; }
        #footer {
            background: #ffffff; padding: 12px 10px; text-align: center;
            border-top: 1px solid #ddd; display: flex; flex-direction: column;
            align-items: center; gap: 8px;
        }
        .footer-logo { height: 25px; background: #ffffff; padding: 4px 8px; border-radius: 4px; }
        .footer-credits { font-size: 0.75rem; color: #666; line-height: 1.5; }
        .footer-credits a { color: #555; text-decoration: underline; }
        #info-box {
            position: absolute; top: 15px; left: 50%; transform: translateX(-50%);
            z-index: 1000; background: rgba(255, 255, 255, 0.95);
            padding: 15px 20px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);
            text-align: center; width: 85%; max-width: 350px; display: none; color: #222;
        }
        #expired-msg {
            position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
            background: white; padding: 40px; border-radius: 12px; text-align: center;
            display: none; width: 80%; max-width: 400px; color: #333;
        }
        .car-icon { filter: drop-shadow(0px 3px 3px rgba(0,0,0,0.4)); }
    </style>
</head>
<body>
<div id="container">
    <div id="header">Live Standort von Moneypenny</div>
    <div id="map-wrapper">
        <div id="info-box">Warte auf GPS-Daten...</div>
        <div id="expired-msg">
            <svg style="width:50px;height:50px;margin-bottom:15px;" viewBox="0 0 24 24" fill="#666"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z"/></svg>
            <br><b>Die Live-Standortfreigabe wurde beendet.</b>
        </div>
        <div id="map"></div>
    </div>
    <div id="footer">
        <a href="https://www.johnlose.de" target="_blank" rel="noopener">
            <img src="https://www.johnlose.de/wp-content/uploads/2015/06/Logo-JL-SW_Web_141.png" alt="Logo" class="footer-logo">
        </a>
        <div class="footer-credits">
            Daten bereitgestellt von <a href="https://share.tessie.com/pFejzm9eN1n" target="_blank" rel="noopener">Tessie</a> | 
            Karten von <a href="https://supporting.openstreetmap.org/donate/" target="_blank" rel="noopener">OpenStreetMap</a><br>
            <a href="https://www.johnlose.de/impressum/" target="_blank" rel="noopener">Impressum</a>
        </div>
    </div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
    var map = L.map('map', { zoomControl: false }).setView([51.165, 10.451], 6);
    L.control.zoom({ position: 'bottomright' }).addTo(map); 
    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
    var carSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#007BFF"><path d="M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z"/></svg>';
    var carIcon = L.icon({ iconUrl: 'data:image/svg+xml;base64,' + btoa(carSvg), iconSize: [36, 36], iconAnchor: [18, 18], className: 'car-icon' });
    var marker = null; var isFirstLoad = true; 

    // HILFSFUNKTION FÜR XSS-SCHUTZ
    function escapeHTML(str) {
        if (str === null || str === undefined) return '';
        var div = document.createElement('div');
        div.appendChild(document.createTextNode(str));
        return div.innerHTML;
    }

    function fetchLocation() {
        fetch('data.json?t=' + new Date().getTime())
            .then(response => response.json())
            .then(data => {
                if (data.status === 'expired') {
                    document.getElementById('map').style.display = 'none';
                    document.getElementById('info-box').style.display = 'none';
                    document.getElementById('expired-msg').style.display = 'block';
                } else if (data.status === 'active') {
                    document.getElementById('map').style.display = 'block';
                    map.invalidateSize(); 
                    document.getElementById('expired-msg').style.display = 'none';
                    document.getElementById('info-box').style.display = 'block';
                    
                    var lat = parseFloat(data.lat); var lon = parseFloat(data.lon);
                    if (!marker) { marker = L.marker([lat, lon], {icon: carIcon}).addTo(map); } 
                    else { marker.setLatLng([lat, lon]); }
                    
                    if (isFirstLoad) { map.setView([lat, lon], 14); isFirstLoad = false; }
                    
                    // ESCAPEHTML() WIRD HIER FÜR DAS ZIEL VERWENDET
                    document.getElementById('info-box').innerHTML = "<b>Ziel:</b> " + escapeHTML(data.dest) + "<br><b>Ankunft in:</b> " + Math.round(data.eta) + " Minuten<br><b>Akku bei Ankunft:</b> " + Math.round(data.soc_arrival) + "%";
                }
            }).catch(error => console.log('Warte auf Daten...'));
    }
    fetchLocation(); setInterval(fetchLocation, 5000);
</script>
</body>
</html>

Home Assistant Helper (Timer) einrichten

Kommen wir zum Home Assistant. Ich benötige zunächst einen Timer. Das gehört in meine configuration.yaml:

input_select:
  standort_teilen:
    name: Standort teilen
    options:
      - "Aus"
      - "60"
      - "120"
      - "180"
    icon: mdi:share-variant

timer:
  standort_timer:
    name: Standort Timer

Home Assistant – Schnittstelle zum Webspace

Damit Home Assistant die Daten per Push an meinen Webserver senden kann, richte ich ein rest_command in der configuration.yaml ein. Hier baue ich auch eine Logik ein, die die ETA in Minuten umrechnet und einspringt, falls das Auto gerade gar kein Navigations-Ziel aktiv hat.

(Achtung: Passe die Sensor-Namen deines Teslas an!)

rest_command:
  update_tesla_location:
    url: "https://DEINE-WEBSITE.de/shareloc/update.php" 
    method: POST
    content_type: 'application/json'
    payload: >-
      {% set arrival = states('sensor.deintesla_zeit_bis_zur_ankunft') %}
      {% set eta = 0 %}
      {% if arrival not in ['unknown', 'unavailable', 'none', ''] %}
        {% set eta = ((as_timestamp(arrival) - as_timestamp(now())) / 60) | round(0) %}
      {% endif %}
      {% set ziel = states('sensor.deintesla_ziel') %}
      {
        "secret": "DEIN_PASSWORT_HIER",
        "status": "{{ status }}",
        "lat": "{{ state_attr('device_tracker.deintesla_standort', 'latitude') if status == 'active' else 0 }}",
        "lon": "{{ state_attr('device_tracker.deintesla_standort', 'longitude') if status == 'active' else 0 }}",
        "dest": "{{ ('Ohne festes Ziel' if ziel in ['unknown', 'unavailable', 'none', ''] else ziel) if status == 'active' else 'Kein Ziel (Freigabe beendet)' }}",
        "eta": {{ eta if status == 'active' else 0 }},
        "soc_arrival": {{ states('sensor.deintesla_ladezustand_bei_ankunft') | float(0) | round(0) if status == 'active' else 0 }}
      }

Finale: Automatisierungen

Jetzt füge ich alles zusammen. Diese zwei Automatisierungen brauche ich dafür:

Automatisierung 1: Timer

Diese Automatisierung startet den Timer, wenn du das Dropdown bedienst. Wenn der Timer abläuft, schickt sie sofort ein „expired“ an die Webseite, wodurch die Karte für alle Gäste sofort gelöscht wird.

alias: "Tesla: Standort Timer Steuerung"
mode: restart
trigger:
  - platform: state
    entity_id: input_select.standort_teilen
    id: dropdown
  - platform: event
    event_type: timer.finished
    event_data:
      entity_id: timer.standort_timer
    id: timer_done
action:
  - choose:
      # Timer manuell starten
      - conditions:
          - condition: trigger
            id: dropdown
          - condition: state
            entity_id: input_select.standort_teilen
            state:
              - "60"
              - "120"
              - "180"
        sequence:
          - service: timer.start
            target:
              entity_id: timer.standort_timer
            data:
              duration: "{{ states('input_select.standort_teilen') | int * 60 }}"
          - service: rest_command.update_tesla_location
            data:
              status: active

            # Beenden (Manuell oder Timer abgelaufen)
      - conditions:
          - condition: or
            conditions:
              - condition: trigger
                id: timer_done
              - condition: and
                conditions:
                  - condition: trigger
                    id: dropdown
                  - condition: state
                    entity_id: input_select.standort_teilen
                    state: "Aus"
                  - condition: state
                    entity_id: timer.standort_timer
                    state: "active"
        sequence:
          - service: timer.cancel
            target:
              entity_id: timer.standort_timer
          - service: input_select.select_option
            target:
              entity_id: input_select.standort_teilen
            data:
              option: Aus
          - service: rest_command.update_tesla_location
            data:
              status: expired
          - service: notify.pushover
            data:
              title: "Standortfreigabe beendet"
              message: "Die Live-Standortfreigabe für Dein Fahrzeug ist abgelaufen."

Automatisierung 2: GPS Push beim Fahren

Das Ding lädt die Daten nur hoch, wenn sich der Standort deines Autos ändert und der Timer gerade aktiv ist.

alias: "Tesla: Live-Standort pushen"
trigger:
  - platform: state
    entity_id: device_tracker.deintesla_standort
condition:
  - condition: state
    entity_id: timer.standort_timer
    state: "active"
action:
  - service: rest_command.update_tesla_location
    data:
      status: "active"

Zum Schluss benötige ich noch irgendwas, auf das ich klicken kann, was z.B. die SMS-App öffnet, um meinen Standort zu teilen. Dieser „Teilen via SMS“ Button öffnet direkt deine SMS/iMessage-App auf dem Handy mit einem vorbereiteten Text, in dem dein Link steht.

type: entities
title: Share Location
entities:
  - entity: input_select.standort_teilen
  - entity: timer.standort_timer
  - type: button
    name: Teilen via SMS
    icon: mdi:message-text-outline
    tap_action:
      action: url
      url_path: >-
        sms:?body=Verfolge%20meine%20Fahrt%20live%3A%20https%3A%2F%2FDEINE-WEBSITE.de%2Fshareloc%2F

Das müsste es gewesen sein. Falls Dir das zu viel ist, kannst Du z.B. Antigravity mit Deinem Home Assistant verbinden (Vorher Backup machen!) und auf diese Webseite als Codebeispiel verweisen. Dann sollte das ebenfalls klappen.

Tipp: Du kannst ein Dashboard bauen, welches Du im Browser von Deinem Fahrzeug hinterlegst, z.B. dort, wo Du auch die Garagensteuerung findest.

Ich bin dankbar, für die Darstellungsmöglichkeiten der Kartendaten Leaflet von Volodymyr Agafonkin (Link) nutzen zu können, sowie Openstreetmaps.org (Link). Ich nutze die original Tessie Integration für Home Assistant (Link).