Files
mqtt-clawdbot-bridge/arrival-notifier.js
Clawd 9b1d496e13 Add arrival notifier for Tesla/TeslaMate
- Monitors state changes (driving → parked/charging)
- Uses geofences when available
- Falls back to reverse geocoding via Nominatim
- Triggers Clawdbot agent to check location-based reminders
- Includes systemd service template
- 30s debounce to prevent duplicate notifications
2026-01-28 12:02:26 +00:00

253 lines
7.1 KiB
JavaScript

#!/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/moving, now parked or charging
const wasMoving = ['driving', 'online'].includes(state.previousState);
const nowStopped = ['parked', 'charging'].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,
};
const client = mqtt.connect(config.mqtt.url, mqttOptions);
const prefix = `teslamate/cars/${config.carId}`;
client.on('connect', () => {
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...'));
// 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);
});