#!/usr/bin/env node /** * Tesla Arrival Notifier * * Monitors TeslaMate MQTT topics and notifies Clawdbot when you arrive somewhere. * Uses geofences when available, reverse geocoding for unknown locations. * * Environment variables: * MQTT_URL, MQTT_USERNAME, MQTT_PASSWORD - MQTT connection * CLAWDBOT_URL, CLAWDBOT_TOKEN - Clawdbot webhook * NOMINATIM_URL - Reverse geocoding (default: OpenStreetMap) * CAR_ID - TeslaMate car ID (default: 14) */ import mqtt from 'mqtt'; const config = { mqtt: { url: process.env.MQTT_URL || 'mqtts://mqtt.teslamate.olex.me:8883', username: process.env.MQTT_USERNAME || 'olex', password: process.env.MQTT_PASSWORD, clientId: `arrival-notifier-${Math.random().toString(16).slice(2, 8)}`, }, clawdbot: { url: process.env.CLAWDBOT_URL || 'http://127.0.0.1:18789', token: process.env.CLAWDBOT_TOKEN, }, nominatim: { url: process.env.NOMINATIM_URL || 'https://nominatim.openstreetmap.org', }, carId: process.env.CAR_ID || '14', debounceMs: 30000, // Ignore arrivals within 30s of each other }; // State tracking const state = { currentState: null, // driving, parked, charging, etc. previousState: null, geofence: null, latitude: null, longitude: null, lastArrivalTime: 0, }; const log = { info: (...args) => console.log('[INFO]', new Date().toISOString(), ...args), debug: (...args) => process.env.LOG_LEVEL === 'debug' && console.log('[DEBUG]', new Date().toISOString(), ...args), error: (...args) => console.error('[ERROR]', new Date().toISOString(), ...args), }; /** * Reverse geocode coordinates to address/POI */ async function reverseGeocode(lat, lon) { try { const url = `${config.nominatim.url}/reverse?lat=${lat}&lon=${lon}&format=json&addressdetails=1&zoom=18`; const response = await fetch(url, { headers: { 'User-Agent': 'TeslaArrivalNotifier/1.0' } }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); // Build a human-readable location description const addr = data.address || {}; const parts = []; // POI name if available if (data.name && data.name !== addr.road) { parts.push(data.name); } // Street address if (addr.road) { parts.push(addr.house_number ? `${addr.road} ${addr.house_number}` : addr.road); } // City/town const city = addr.city || addr.town || addr.village || addr.municipality; if (city) parts.push(city); return { display: parts.join(', ') || data.display_name, type: data.type, category: data.category, address: addr, }; } catch (err) { log.error('Reverse geocode failed:', err.message); return { display: `${lat.toFixed(5)}, ${lon.toFixed(5)}`, type: 'coordinates' }; } } /** * Send arrival notification to Clawdbot */ async function notifyArrival(location, isGeofence) { const now = Date.now(); // Debounce if (now - state.lastArrivalTime < config.debounceMs) { log.debug('Debounced arrival notification'); return; } state.lastArrivalTime = now; const locationType = isGeofence ? 'geofence' : 'geocoded'; const message = `[Arrival] Olex arrived at: ${location.display || location} Location type: ${locationType} Coordinates: ${state.latitude}, ${state.longitude} Time: ${new Date().toLocaleString('de-DE', { timeZone: 'Europe/Berlin' })} Check location-reminders.json and your notes for any reminders relevant to this location. If there are relevant reminders, send them to Olex. If not, stay silent (HEARTBEAT_OK).`; try { const response = await fetch(`${config.clawdbot.url}/hooks/agent`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.clawdbot.token}`, }, body: JSON.stringify({ message, name: 'Arrival', deliver: true, channel: 'telegram', to: '157917114', }), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`); } log.info(`Notified arrival at: ${location.display || location}`); } catch (err) { log.error('Failed to notify Clawdbot:', err.message); } } /** * Handle state change - detect arrivals */ async function handleStateChange(newState) { state.previousState = state.currentState; state.currentState = newState; log.debug(`State: ${state.previousState} → ${newState}`); // Detect arrival: was driving, now stationary // TeslaMate states: driving, online, parked, charging, suspended, asleep, offline const wasMoving = state.previousState === 'driving'; const nowStopped = ['online', 'parked', 'charging', 'suspended', 'asleep', 'offline'].includes(newState); if (wasMoving && nowStopped) { log.info('Arrival detected!'); // Check if we have a geofence if (state.geofence && state.geofence !== 'nil' && state.geofence !== '') { await notifyArrival({ display: state.geofence }, true); } else if (state.latitude && state.longitude) { // Reverse geocode const location = await reverseGeocode(state.latitude, state.longitude); await notifyArrival(location, false); } else { log.error('No location data available for arrival'); } } } /** * Main */ async function main() { if (!config.clawdbot.token) { log.error('CLAWDBOT_TOKEN required'); process.exit(1); } log.info('Starting Tesla Arrival Notifier'); log.info(`MQTT: ${config.mqtt.url}`); log.info(`Car ID: ${config.carId}`); const mqttOptions = { clientId: config.mqtt.clientId, username: config.mqtt.username, password: config.mqtt.password, rejectUnauthorized: true, keepalive: 60, // Send ping every 60s reconnectPeriod: 5000, // Retry every 5s on disconnect connectTimeout: 30000, // 30s connection timeout }; const client = mqtt.connect(config.mqtt.url, mqttOptions); const prefix = `teslamate/cars/${config.carId}`; let isConnected = false; client.on('connect', () => { isConnected = true; log.info('Connected to MQTT'); // Subscribe to relevant topics const topics = [ `${prefix}/state`, `${prefix}/geofence`, `${prefix}/latitude`, `${prefix}/longitude`, ]; topics.forEach(topic => { client.subscribe(topic, (err) => { if (err) log.error(`Subscribe failed: ${topic}`); else log.debug(`Subscribed: ${topic}`); }); }); }); client.on('message', async (topic, payload) => { const value = payload.toString(); const field = topic.split('/').pop(); log.debug(`${field}: ${value}`); switch (field) { case 'state': await handleStateChange(value); break; case 'geofence': state.geofence = value; break; case 'latitude': state.latitude = parseFloat(value); break; case 'longitude': state.longitude = parseFloat(value); break; } }); client.on('error', (err) => log.error('MQTT error:', err.message)); client.on('reconnect', () => { log.info('Reconnecting to MQTT...'); }); client.on('close', () => { if (isConnected) { log.info('MQTT connection closed'); isConnected = false; } }); client.on('offline', () => { log.info('MQTT client offline'); isConnected = false; }); // Graceful shutdown process.on('SIGINT', () => { log.info('Shutting down...'); client.end(true, () => process.exit(0)); }); process.on('SIGTERM', () => { client.end(true, () => process.exit(0)); }); } main().catch(err => { log.error('Fatal:', err.message); process.exit(1); });