- 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
253 lines
7.1 KiB
JavaScript
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);
|
|
});
|