iobroker.traccar
Version:
Connects to a Traccar server to fetch geo positions of connected devices
485 lines (424 loc) • 19.1 kB
JavaScript
;
/*
* Created with @iobroker/create-adapter v1.27.0
*/
// The adapter-core module gives you access to the core ioBroker functions
// you need to create an adapter
const utils = require('@iobroker/adapter-core');
// Load your modules here, e.g.:
const axios = require('axios').default;
const WebSocket = require('ws');
const defObj = require('./lib/object_definitions').defObj;
let cookie;
let ws;
let devices;
let positions;
let geofences;
const geofencesNow = [];
let ping;
let pingTimeout;
let autoRestartTimeout;
const wsHeartbeatIntervall = 30000;
const restartTimeout = 10000;
class Traccar extends utils.Adapter {
/**
* @param {Partial<utils.AdapterOptions>} [options={}]
*/
constructor(options) {
super({
...options,
name: 'traccar',
});
this.on('ready', this.onReady.bind(this));
this.on('unload', this.onUnload.bind(this));
}
/**
* Is called when databases are connected and adapter received configuration.
*/
async onReady() {
// Reset adapter connection
this.setState('info.connection', false, true);
// Log configuration
this.log.debug(`Scheme: ${this.config.traccarScheme}`);
this.log.debug(`Server IP: ${this.config.traccarIp}`);
this.log.debug(`Port: ${this.config.traccarPort}`);
this.log.debug(`Username: ${this.config.traccarUsername}`);
this.log.debug(`Password: ${this.config.traccarPassword !== '' ? '**********' : 'no password configured'}`);
// Adapter is up and running
this.log.debug('Adapter is up and running');
// Get autuh cookie for websocket
try {
await this.authUser();
// Get initial traccar data over HTTP-API
await this.getTraccarDataOverAPI();
this.processData(); // first try
// Connect websocket
this.initWebsocket();
} catch (error) {
this.log.debug(JSON.stringify(error));
this.log.warn('Server is offline or the address is incorrect!');
this.autoRestart();
this.setState('info.connection', false, true);
}
}
/**
* Is called when adapter shuts down - callback has to be called under any circumstances!
* @param {() => void} callback
*/
async onUnload(callback) {
try {
this.clearTimeout(ping);
this.clearTimeout(pingTimeout);
this.clearTimeout(autoRestartTimeout);
// Reset adapter connection
this.setState('info.connection', false, true);
callback();
} catch (e) {
callback();
}
}
/**
* Is called to update Traccar data
*/
async authUser() {
const auth = `email=${encodeURIComponent(this.config.traccarUsername)}&password=${encodeURIComponent(this.config.traccarPassword)}`;
const axiosOptions = {
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
};
// Get Cookie
const resp = await axios.post(`${this.config.traccarScheme}://${this.config.traccarIp}:${this.config.traccarPort}/api/session`, auth, axiosOptions);
if (resp && resp.headers && resp.headers['set-cookie']) {
cookie = resp.headers['set-cookie'][0];
this.log.debug('Auth succses, cookie: ' + cookie);
}
}
async initWebsocket() {
// Set websocket connection
ws = new WebSocket(`${this.config.traccarScheme == 'http' ? 'ws' : 'wss'}://${this.config.traccarIp}:${this.config.traccarPort}/api/socket`, { headers: { Cookie: cookie } });
// On connect
ws.on('open', () => {
this.log.debug('Websocket connectet');
// Set connection state
this.setState('info.connection', true, true);
this.log.info('Connect to server over websocket connection.');
// Send ping to server
this.sendPingToServer();
// Start Heartbeat
this.wsHeartbeat();
});
// Incomming messages
ws.on('message', async (message) => {
this.log.debug(`Incomming message: ${message}`);
const obj = JSON.parse(message);
const objName = Object.keys(obj)[0];
// Clean positions;
positions = [];
// Position message
if (objName == 'positions') {
positions = obj.positions;
await this.processPosition();
}
// Device message
if (objName == 'devices') {
for (const key in obj.devices) {
const index = devices.findIndex((x) => x.id == obj.devices[key].id);
if (index == -1) {
await this.getTraccarDataOverAPI();
return;
}
devices[index] = obj.devices[key];
}
await this.processData();
}
});
// On Close
ws.on('close', () => {
this.setState('info.connection', false, true);
this.log.warn('Websocket disconnectet');
clearTimeout(ping);
clearTimeout(pingTimeout);
if (ws.readyState === WebSocket.CLOSED) {
this.autoRestart();
}
});
// Pong from Server
ws.on('pong', () => {
this.log.debug('Receive pong from server');
this.wsHeartbeat();
});
}
async sendPingToServer() {
this.log.debug('Send ping to server');
ws.ping('iobroker.traccar');
ping = setTimeout(() => {
this.sendPingToServer();
}, wsHeartbeatIntervall);
}
async wsHeartbeat() {
clearTimeout(pingTimeout);
pingTimeout = setTimeout(() => {
this.log.debug('Websocked connection timed out');
ws.terminate();
}, wsHeartbeatIntervall + 1000);
}
async autoRestart() {
this.log.warn(`Start try again in ${restartTimeout / 1000} seconds...`);
autoRestartTimeout = setTimeout(() => {
this.onReady();
}, restartTimeout);
}
async processData() {
// Process devices
this.setObjectAndState('devices', 'devices');
for (const device of devices) {
const stateBaseID = `devices.${device.id}`;
// Create static datapoins
this.setObjectAndState('devices.device', stateBaseID, device.name);
this.setObjectAndState('devices.device.device_name', `${stateBaseID}.device_name`, null, device.name);
this.setObjectAndState('devices.device.status', `${stateBaseID}.status`, null, device.status);
this.setObjectAndState('devices.device.last_update_from_server', `${stateBaseID}.last_update_from_server`, null, device.lastUpdate);
this.setObjectAndState('devices.device.last_update', `${stateBaseID}.last_update`, null, Number(Date.now()));
// Server < v5.8
if (device.geofenceIds) {
const deviceGeofencesState = await this.getGeofencesState(device.geofenceIds);
this.setObjectAndState('devices.device.geofence_ids', `${stateBaseID}.geofence_ids`, null, JSON.stringify(device.geofenceIds));
this.setObjectAndState('devices.device.geofences', `${stateBaseID}.geofences`, null, JSON.stringify(deviceGeofencesState));
this.setObjectAndState('devices.device.geofences_string', `${stateBaseID}.geofences_string`, null, deviceGeofencesState.join(', '));
}
}
await this.processGeofences();
}
async processPosition() {
// const position = positions.find((p) => p.deviceId === device.id);
for (const position of positions) {
const stateBaseID = `devices.${position.deviceId}`;
// Create static datapoins
this.setObjectAndState('devices.device.altitude', `${stateBaseID}.altitude`, null, Number(parseFloat(position.altitude).toFixed(1)));
this.setObjectAndState('devices.device.accuracy', `${stateBaseID}.accuracy`, null, Number(parseFloat(position.accuracy).toFixed(2)));
this.setObjectAndState('devices.device.course', `${stateBaseID}.course`, null, position.course);
this.setObjectAndState('devices.device.latitude', `${stateBaseID}.latitude`, null, position.latitude);
this.setObjectAndState('devices.device.longitude', `${stateBaseID}.longitude`, null, position.longitude);
this.setObjectAndState('devices.device.position', `${stateBaseID}.position`, null, `${position.latitude},${position.longitude}`);
this.setObjectAndState('devices.device.position_url', `${stateBaseID}.position_url`, null, `https://maps.google.com/maps?z=15&t=m&q=loc:${position.latitude}+${position.longitude}`);
this.setObjectAndState('devices.device.speed', `${stateBaseID}.speed`, null, Number(Number(position.speed).toFixed()));
this.setObjectAndState('devices.device.outdated', `${stateBaseID}.outdated`, null, position.outdated);
this.setObjectAndState('devices.device.last_update', `${stateBaseID}.last_update`, null, Number(Date.now()));
// Server >= v5.8
let geofenceIds = '';
if (position.geofenceIds) {
const positionGeofencesState = await this.getGeofencesState(position.geofenceIds);
geofenceIds = JSON.stringify(position.geofenceIds);
this.setObjectAndState('devices.device.geofence_ids', `${stateBaseID}.geofence_ids`, null, geofenceIds);
this.setObjectAndState('devices.device.geofences', `${stateBaseID}.geofences`, null, JSON.stringify(positionGeofencesState));
this.setObjectAndState('devices.device.geofences_string', `${stateBaseID}.geofences_string`, null, positionGeofencesState.join(', '));
} else {
this.setObjectAndState('devices.device.geofence_ids', `${stateBaseID}.geofence_ids`, null, '[]');
this.setObjectAndState('devices.device.geofences', `${stateBaseID}.geofences`, null, '[]');
this.setObjectAndState('devices.device.geofences_string', `${stateBaseID}.geofences_string`, null, '');
}
geofencesNow[position.deviceId] = geofenceIds;
// Address is optional
if (position.address) {
this.setObjectAndState('devices.device.address', `${stateBaseID}.address`, null, position.address);
}
// Create dynamic datapoints for attributes
for (const key in position.attributes) {
await this.createObjectAndState(position.deviceId, position.attributes, key);
}
}
await this.processGeofences();
}
async processGeofences() {
// Process geofences
this.setObjectAndState('geofences', 'geofences');
for (const geofence of geofences) {
const stateBaseID = `geofences.${geofence.id}`;
// Create static datapoins
const geoDeviceState = this.getGeoDeviceState(geofence, this.config.server58);
this.setObjectAndState('geofences.geofence', stateBaseID, geofence.name);
this.setObjectAndState('geofences.geofence.geofence_name', `${stateBaseID}.geofence_name`, null, geofence.name);
this.setObjectAndState('geofences.geofence.device_ids', `${stateBaseID}.device_ids`, null, JSON.stringify(geoDeviceState[0]));
this.setObjectAndState('geofences.geofence.devices', `${stateBaseID}.devices`, null, JSON.stringify(geoDeviceState[1]));
this.setObjectAndState('geofences.geofence.devices_string', `${stateBaseID}.devices_string`, null, geoDeviceState[1].join(', '));
this.setObjectAndState('geofences.geofence.last_update', `${stateBaseID}.last_update`, null, Number(Date.now()));
}
}
/**
* Is called to update Traccar data
*/
async getTraccarDataOverAPI() {
const baseUrl = `${this.config.traccarScheme}://${this.config.traccarIp}:${this.config.traccarPort}/api`;
const axiosOptions = {
auth: {
username: this.config.traccarUsername,
password: this.config.traccarPassword,
},
};
const responses = await axios.all([axios.get(`${baseUrl}/devices`, axiosOptions), axios.get(`${baseUrl}/positions`, axiosOptions), axios.get(`${baseUrl}/geofences`, axiosOptions)]);
for (const key in responses) {
this.log.debug(JSON.stringify(responses[key].data));
}
devices = responses[0].data;
positions = responses[1].data;
geofences = responses[2].data;
// const server = await axios.all([axios.get(`${baseUrl}/server`, axiosOptions), axios.get(`${baseUrl}/positions`, axiosOptions), axios.get(`${baseUrl}/geofences`, axiosOptions)]);
}
async getGeofencesState(geofenceIds) {
const geofencesState = [];
if (geofenceIds) {
for (const geofenceId of geofenceIds) {
const geofence = geofences.find((element) => element.id === geofenceId);
// Workaround for unclean geofences in the database
if (!geofence || !geofence.name) {
await this.getTraccarDataOverAPI();
} else {
geofencesState.push(geofence.name);
}
}
}
return geofencesState;
}
getGeoDeviceState(geofence, serverVersion58) {
const deviceIdsState = [];
const devicesState = [];
if (serverVersion58 == true) {
for (let i = 0; i < geofencesNow.length; i++) {
if (geofencesNow[i] != null) {
if (geofencesNow[i].includes(geofence.id)) {
deviceIdsState.push(i);
const found = devices.find(({id}) => id === i);
devicesState.push(found.name);
}
}
}
} else {
for (const device of devices) {
if (device.geofenceIds) {
if (device.geofenceIds.includes(geofence.id)) {
deviceIdsState.push(device.id);
devicesState.push(device.name);
}
}
}
}
return [deviceIdsState, devicesState];
}
getGeoDeviceStateold(geofence, serverVersion58) {
const deviceIdsState = [];
const devicesState = [];
if (serverVersion58 == true) {
for (const position of positions) {
if (position.geofenceIds) {
if (position.geofenceIds.includes(geofence.id)) {
deviceIdsState.push(position.deviceId);
const found = devices.find(({ id }) => id === position.deviceId);
devicesState.push(found.name);
}
}
}
} else {
for (const device of devices) {
if (device.geofenceIds) {
if (device.geofenceIds.includes(geofence.id)) {
deviceIdsState.push(device.id);
devicesState.push(device.name);
}
}
}
}
return [deviceIdsState, devicesState];
}
async createObjectAndState(deviceId, obj, key) {
let val = obj[key];
if (typeof val === 'object' && !Array.isArray(val)) {
for (const objKey in val) {
const objVal = val[objKey];
const stateID = `devices.${deviceId}.${this.formatName(objKey)}`;
const objID = `devices.device.${this.formatName(objKey)}`;
this.log.debug(`objID: ${objID}, val: ${objVal}`);
if (!objKey[0].match(/[A-z]/i)) {
this.log.warn(`${objKey} does not start with a letter, this will cause problems with the state name therefore this value must be ignored!`);
}
this.setObjectAndState(objID, stateID, this.formatStateName(objKey), objVal);
}
} else {
const stateID = `devices.${deviceId}.${this.formatName(key)}`;
const objID = `devices.device.${this.formatName(key)}`;
this.log.debug(`objID: ${objID}, val: ${val}`);
if (Array.isArray(val)) {
val = JSON.stringify(val);
}
if (!key[0].match(/[A-z]/i)) {
this.log.warn(`${key} does not start with a letter, this will cause problems with the state name therefore this value must be ignored!`);
}
this.setObjectAndState(objID, stateID, this.formatStateName(key), val);
}
}
/**
* Is used to create and object and set the value
* @param {string} objectId
* @param {string} stateId
* @param {string | null} stateName
* @param {*} value
*/
async setObjectAndState(objectId, stateId, stateName = null, value = null) {
let obj;
if (defObj[objectId]) {
obj = defObj[objectId];
} else {
obj = {
type: 'state',
common: {
name: stateName,
type: 'mixed',
role: 'state',
read: true,
write: true,
},
native: {},
};
}
if (stateName !== null) {
obj.common.name = stateName;
}
await this.setObjectNotExistsAsync(stateId, {
type: obj.type,
common: JSON.parse(JSON.stringify(obj.common)),
native: JSON.parse(JSON.stringify(obj.native)),
});
if (value !== null) {
await this.setStateChangedAsync(stateId, {
val: value,
ack: true,
});
}
}
formatName(input) {
const wordArray = input.split(/(?=[A-Z])/);
return wordArray.join('_').toLowerCase();
}
formatStateName(input) {
const wordArray = input.split(/(?=[A-Z])/);
for (const key in wordArray) {
if (key === '0') {
wordArray[key] = wordArray[key][0].toUpperCase() + wordArray[key].substr(1);
} else {
wordArray[key] = wordArray[key].toLowerCase();
}
}
return wordArray.join(' ');
}
}
// @ts-ignore parent is a valid property on module
if (module.parent) {
// Export the constructor in compact mode
/**
* @param {Partial<utils.AdapterOptions>} [options={}]
*/
module.exports = (options) => new Traccar(options);
} else {
// otherwise start the instance directly
new Traccar();
}