pimatic-bluelink
Version:
Pimatic plugin for Bluelink connected cars
829 lines (738 loc) • 29.4 kB
text/coffeescript
module.exports = (env) ->
Promise = env.require 'bluebird'
assert = env.require 'cassert'
M = env.matcher
_ = require('lodash')
fs = require('fs')
path = require('path')
#Bluelinky = require('kuvork')
Bluelinky = require('bluelinky')
class BluelinkPlugin extends env.plugins.Plugin
init: (app, , ) =>
pluginConfigDef = require './pimatic-bluelink-config-schema'
= require("./device-config-schema")
= .username # "email@domain.com";
= .password #"a1b2c3d4";
= .region ? 'EU'
= .pin
options =
username:
password:
region:
pin:
= .brand ? "kia"
if is "hyundai"
#Bluelinky = require('bluelinky')
= "HyundaiDevice"
options["brand"] = "hyundai"
else
#Bluelinky = require('kuvork')
= "KiaDevice"
options["brand"] = "kia"
options["vin"] = "KNA" #for detecting the Kia or Hyandai api, brand deduction: VIN numbers of KIA are KNA/KNC/KNE
= null
= false
.on 'after init', ()=>
mobileFrontend = .pluginManager.getPlugin 'mobile-frontend'
if mobileFrontend?
mobileFrontend.registerAssetFile 'js', "pimatic-bluelink/app/bluelink.coffee"
mobileFrontend.registerAssetFile 'html', "pimatic-bluelink/app/bluelink.jade"
mobileFrontend.registerAssetFile 'css', "pimatic-bluelink/app/bluelink.css"
= new Bluelinky(options)
.on 'ready',() =>
env.logger.debug "Plugin emit clientReady"
= true
"clientReady"
.on 'error',(err) =>
env.logger.debug "Bluelink login error: " + JSON.stringify(err,null,2)
= false
.deviceManager.registerDeviceClass('KiaDevice', {
configDef: .KiaDevice,
createCallback: (config, lastState) => new KiaDevice(config, lastState, @, , )
})
.deviceManager.registerDeviceClass('HyundaiDevice', {
configDef: .HyundaiDevice,
createCallback: (config, lastState) => new HyundaiDevice(config, lastState, @, , )
})
.ruleManager.addActionProvider(new BluelinkActionProvider())
.deviceManager.on('discover', (eventData) =>
.deviceManager.discoverMessage 'pimatic-bluelink', 'Searching for new devices'
if
.getVehicles()
.then((vehicles) =>
for vehicle in vehicles
carConfig = vehicle.vehicleConfig
env.logger.info "CarConfig: " + JSON.stringify(carConfig,null,2)
_did = (carConfig.nickname).split(' ').join("_")
if _.find(.deviceManager.devicesConfig,(d) => d.id is _did)
env.logger.info "Device '" + _did + "' already in config"
else
config =
id: _did #(carConfig.nickname).split(' ').join("_").toLowerCase()
name: carConfig.nickname
class:
vin: carConfig.vin
vehicleId: carConfig.id
type: carConfig.name
.deviceManager.discoveredDevice( "Bluelink", config.name, config)
).catch((e) =>
env.logger.error 'Error in getVehicles: ' + e.message
)
)
class BluelinkDevice extends env.devices.Device
template: "bluelink"
actions:
changeActionTo:
description: "Sets the action"
params:
action:
type: "string"
attributes:
engine:
description: "Status of engine"
type: "boolean"
acronym: "engine"
labels: ["on","off"]
hidden: false
airco:
description: "Status of airco"
type: "string"
acronym: "airco"
enum: ["start","startPlus","off"]
hidden: true
door:
description: "Status of doorlock"
type: "boolean"
acronym: "door"
labels: ["locked","unlocked"]
hidden: true
charging:
description: "If vehicle is charging"
type: "boolean"
acronym: "charging"
labels: ["on","off"]
hidden: true
pluggedIn:
description: "If vehicle is pluggedIn"
type: "string"
acronym: "batteryPlugin"
chargingTime:
description: "Time left for charging"
type: "string"
acronym: "charging time"
doorFrontLeft:
description: "door fl"
type: "boolean"
acronym: "door fl"
labels: ["opened","closed"]
doorFrontRight:
description: "door fr"
type: "boolean"
acronym: "door fr"
labels: ["opened","closed"]
doorBackLeft:
description: "door bl"
type: "boolean"
acronym: "door bl"
labels: ["opened","closed"]
doorBackRight:
description: "door br"
type: "boolean"
acronym: "door br"
labels: ["opened","closed"]
hood:
description: "hood"
type: "boolean"
acronym: "hood"
labels: ["opened","closed"]
trunk:
description: "trunk"
type: "boolean"
acronym: "trunk"
labels: ["opened","closed"]
battery:
description: "The battery level"
type: "number"
unit: '%'
acronym: "battery"
remainingRange:
description: "Remaining range basing on current battery resp. fuel level"
type: "number"
acronym: "remaining"
unit: "km"
twelveVoltBattery:
description: "The 12 volt battery level"
type: "number"
unit: '%'
acronym: "12V"
odo:
description: "The car odo value"
type: "number"
unit: 'km'
acronym: "odo"
speed:
description: "Speed of the car"
type: "number"
acronym: "speed"
unit: "km/h"
maximum:
description: "Maximum range basing on current battery resp. fuel level"
type: "string"
acronym: "maximum"
lat:
description: "The cars latitude"
type: "number"
acronym: "lat"
lon:
description: "The cars longitude"
type: "number"
acronym: "lon"
status:
description: "The connection status"
type: "string"
acronym: "status"
getTemplateName: -> "bluelink"
statusCodes =
init: 0
ready: 1
getStatus: 2
getStatusError: 3
commandSend: 4
commandSuccess: 5
commanderror: 6
constructor: (config, lastState, , client, ) ->
= config
= .id
= .name
= .client
= .pollTimePassive ? 3600000 # 1 hour
= .pollTimeActive ? 600000 # 10 minutes
=
= .defrost ? false
= .windscreenHeating ? false
= .temperature ? 20
= .optionsVariable ? ""
= laststate?.engine?.value ? false
= laststate?.speed?.value ? 0
= laststate?.airco?.value ? "off"
= laststate?.door?.value ? false
= laststate?.charging?.value ? false
= laststate?.chargingTime?.value ? 0
= laststate?.battery?.value ? 0
= laststate?.twelveVoltBattery?.value ? 0
= laststate?.pluggedIn?.value ? "unplugged"
= laststate?.odo?.value ? 0
= laststate?.maximum?.value ? 0
= laststate?.remainingRange?.value ? 0
= laststate?.doorFrontLeft?.value ? 0
= laststate?.doorFrontRight?.value ? 0
= laststate?.doorBackLeft?.value ? 0
= laststate?.doorBackRight?.value ? 0
= laststate?.hood?.value ? 0
= laststate?.trunk?.value ? 0
= laststate?.lat?.value ? 0
= laststate?.lon?.value ? 0
= statusCodes.init
retries = 0
maxRetries = 20
= null
###
.xAttributeOptions = [] unless .xAttributeOptions?
for i, _attr of
do (_attr) =>
if _attr.type is 'number'
_hideSparklineNumber =
name: i
displaySparkline: false
.xAttributeOptions.push _hideSparklineNumber
###
.on 'clientReady', = () =>
unless ?
env.logger.debug "Plugin ClientReady, requesting vehicle"
= .client.getVehicle(.vin)
env.logger.debug "From plugin start - starting status update cyle"
# actual car status on start
else
env.logger.debug "Error: plugin start but @statusTimer alredy running!"
.variableManager.waitForInit()
.then ()=>
if .clientReady and not ?
env.logger.debug "ClientReady ready, Device starting, requesting vehicle"
= .client.getVehicle(.vin)
env.logger.debug "From device start - starting status update cyle"
# actual car status on start
= (_refresh=false) =>
if .clientReady
clearTimeout() if ?
env.logger.debug "requesting status, refresh: " + _refresh
.status({refresh:_refresh})
.then (status)=>
return .location()
.then (location)=>
env.logger.debug "location " + JSON.stringify(location,null,2)
return .odometer()
.then (odometer) =>
env.logger.debug "odo " + JSON.stringify(odometer,null,2)
.catch (e) =>
env.logger.debug "getStatus error: " + JSON.stringify(e.body,null,2)
= setTimeout(, )
env.logger.debug "Next poll in " + + " ms"
else
env.logger.debug "(re)requesting status in 5 seconds, client not ready"
retries += 1
if retries < maxRetries
= setTimeout(, 5000)
else
env.logger.debug "Max number of retries(#{maxRetries}) reached, Client not ready, stop trying"
super()
handleLocation: (location) =>
handleOdo: (odo) =>
handleStatus: (status) =>
env.logger.debug "Status: " + JSON.stringify(status,null,2)
if status.doorLock?
if status.doorOpen?
if status.engine?
if status.airCtrlOn?
if status.airCtrlOn
else
if status.battery?.batSoc?
if status.evStatus?
#update polltime to active if engine is on, charging or airco is on
active = (Boolean status.engine) or (Boolean status.evStatus.batteryCharge) or (Boolean status.airCtrlOn)
env.logger.debug "Car status PollTimeActive is " + active
parseOptions: (_options) ->
climateOptions =
defrost:
windscreenHeating:
temperature:
unit: 'C'
if _options?
try
parameters = _options.split(",")
for parameter in parameters
tokens = parameter.split(":")
_key = tokens[0].trim()
_val = tokens[1].trim()
env.logger.debug "_key: " + _key + ", _val: " + _val
switch _key
when "defrost"
climateOptions.defrost = (if _val is "false" then false else true)
when "windscreenHeating"
climateOptions.windscreenHeating = (if _val is "false" then false else true)
when "temperature"
# check if number
unless Number.isNaN(Number _val)
climateOptions.temperature = Number _val
catch err
env.logger.debug "Handled error in parseOptions " + err
return climateOptions
changeActionTo: (action) =>
_action = action
options = null
if action is "startPlus"
if isnt ""
_optionsString = .variableManager.getVariableValue()
if _optionsString?
options = _optionsString
else
return Promise.reject("optionsVariable '#{@_optionsVariable}' does not exsist")
else
return Promise.reject("No optionsVariable defined")
return
execute: (command, options) =>
return new Promise((resolve,reject) =>
unless ? then return reject("No active vehicle")
switch command
when "start"
env.logger.debug "Start with options: " + JSON.stringify(,null,2)
.start()
.then (resp)=>
env.logger.debug "Started: " + JSON.stringify(resp,null,2)
#
# set to active poll
resolve()
.catch (err) =>
env.logger.debug "Error start car: " + JSON.stringify(err,null,2)
reject()
when "startPlus"
env.logger.debug "StartPlus with options: " + JSON.stringify(,null,2)
.start()
.then (resp)=>
env.logger.debug "Started: " + JSON.stringify(resp,null,2)
#
# set to active poll
resolve()
.catch (err) =>
env.logger.debug "Error start car: " + JSON.stringify(err,null,2)
reject()
when "stop"
.stop()
.then (resp)=>
#
env.logger.debug "Stopped: " + JSON.stringify(resp,null,2)
resolve()
.catch (err) =>
env.logger.debug "Error stop car: " + JSON.stringify(err,null,2)
reject()
when "lock"
.lock()
.then (resp)=>
env.logger.debug "Locked: " + JSON.stringify(resp,null,2)
resolve()
.catch (err) =>
env.logger.debug "Error lock car: " + JSON.stringify(err,null,2)
reject()
when "unlock"
.unlock()
.then (resp)=>
# set to active poll
env.logger.debug "Unlocked: " + JSON.stringify(resp,null,2)
resolve()
.catch (err) =>
env.logger.debug "Error unlock car: " + JSON.stringify(err,null,2)
reject()
when "startCharge"
.startCharge()
.then (resp)=>
# set to active poll
env.logger.debug "startCharge: " + JSON.stringify(resp,null,2)
resolve()
.catch (err) =>
env.logger.debug "Error startCharge car: " + JSON.stringify(err,null,2)
reject()
when "stopCharge"
.stopCharge()
.then (resp)=>
env.logger.debug "stopCharge: " + JSON.stringify(resp,null,2)
resolve()
.catch (err) =>
env.logger.debug "Error stopCharge car: " + JSON.stringify(err,null,2)
reject()
when "refresh"
clearTimeout() if ?
env.logger.debug "refreshing status"
resolve()
else
env.logger.debug "Unknown command " + command
reject()
resolve()
)
getEngine: -> Promise.resolve()
getAirco: -> Promise.resolve()
getDoor: -> Promise.resolve()
getDoorFrontLeft: -> Promise.resolve()
getDoorFrontRight: -> Promise.resolve()
getDoorBackLeft: -> Promise.resolve()
getDoorBackRight: -> Promise.resolve()
getHood: -> Promise.resolve()
getTrunk: -> Promise.resolve()
getCharging: -> Promise.resolve()
getBattery: -> Promise.resolve()
getTwelveVoltBattery: -> Promise.resolve()
getPluggedIn: -> Promise.resolve()
getOdo: -> Promise.resolve()
getSpeed: -> Promise.resolve()
getMaximum: -> Promise.resolve()
getRemainingRange: -> Promise.resolve()
getLat: -> Promise.resolve()
getLon: -> Promise.resolve()
getStatus: -> Promise.resolve()
getChargingTime: -> Promise.resolve()
setStatus: (status, command)=>
switch status
when statusCodes.init
_status = "initializing"
when statusCodes.ready
_status = "ready"
when statusCodes.getStatus
_status = "get status"
when statusCodes.getStatusError
_status = "get status error"
when statusCodes.commandSend
_status = "execute " + command
when statusCodes.commandSuccess
_status = command + " executed"
setTimeout(=>
,3000)
when statusCodes.commandError
_status = command + " error"
else
_status = "unknown status " + status
= _status
'status', _status
setPollTime: (active) =>
# true is active, false is passive
if (active and == ) or (!active and == ) then return
#env.logger.debug("Test for active " + active + ", @currentPollTime:"++", @pollTimePassive:"++", == "+ ( == ))
if (active) and ( == )
clearTimeout() if ?
=
env.logger.debug "Switching to active poll, with polltime of " + + " ms"
= setTimeout(, )
return
if not active and ==
clearTimeout() if ?
=
env.logger.debug "Switching to passive poll, with polltime of " + + " ms"
= setTimeout(, )
setMaximum: (_range) =>
= _range
'maximum', _range
setRemainingRange: (_range) =>
= Number _range
'remainingRange', Number _range
setEngine: (_status) =>
= Boolean _status
'engine', Boolean _status
setSpeed: (_status) =>
= Number _status
'speed', Number _status
setDoor: (_status) =>
= Boolean _status
'door', Boolean _status
setTwelveVoltBattery: (_status) =>
= Number _status # Math.round (_status / 2.55 )
'twelveVoltBattery', Number _status # Math.round (_status / 2.55 )
setChargingTime: (_status) =>
= _status
'chargingTime', _status
setDoors: (_status) =>
if _status.doorOpen?
= Boolean _status.doorOpen.frontLeft
'doorFrontLeft', Boolean _status.doorOpen.frontLeft
= Boolean _status.doorOpen.doorFrontRight
'doorFrontRight', Boolean _status.doorOpen.doorFrontRight
= Boolean _status.doorOpen.backLeft
'doorBackLeft', Boolean _status.doorOpen.backLeft
= Boolean _status.doorOpen.backRight
'doorBackRight', Boolean _status.doorOpen.backRight
if _status.trunkOpen?
= Boolean _status.trunkOpen
'trunk', Boolean _status.trunkOpen
if _status.hoodOpen?
= Boolean _status.hoodOpen
'hood', Boolean _status.hoodOpen
setEvStatus: (evStatus) =>
= Number evStatus.batteryStatus
'battery', Number evStatus.batteryStatus
switch evStatus.batteryPlugin
when 0
= "unplugged"
_chargingTime = "no value"
when 1
= "DC"
_chargingTime = evStatus.remainTime2.etc1.value + "min (DC)"
when 2
# = "ACportable"
#_chargingTime = evStatus.remainTime2.etc2.value + "min (ACport)"
= "AC"
_chargingTime = evStatus.remainTime2.etc3.value + "min (AC)"
when 3
= "AC"
_chargingTime = evStatus.remainTime2.etc3.value + "min (AC)"
else
= "unknown"
_chargingTime = ""
'pluggedIn',
= Boolean evStatus.batteryCharge
'charging', Boolean evStatus.batteryCharge
#if
# = true
# 'pluggedIn', (evStatus.batteryPlugin > 0)
# DC maximum
if evStatus.reservChargeInfos.targetSOClist?[0]?.dte?.rangeByFuel?.totalAvailableRange?.value?
_maximumDC = Number evStatus.reservChargeInfos.targetSOClist[0].dte.rangeByFuel.totalAvailableRange.value
_maximumDCperc = Number evStatus.reservChargeInfos.targetSOClist[0].targetSOClevel
if evStatus.reservChargeInfos.targetSOClist?[1]?.dte?.rangeByFuel?.totalAvailableRange?.value?
_maximumAC = Number evStatus.reservChargeInfos.targetSOClist[1].dte.rangeByFuel.totalAvailableRange.value
_maximumACperc = Number evStatus.reservChargeInfos.targetSOClist[1].targetSOClevel
if _maximumDC? and _maximumAC?
_maximum = _maximumDC + "km (DC@" + _maximumDCperc + "%) " + _maximumAC + "km (AC@" + _maximumACperc + "%)"
else
if evStatus.drvDistance?[0]?.rangeByFuel?.totalAvailableRange?.value?
_remainingRange = Number evStatus.drvDistance[0].rangeByFuel.totalAvailableRange.value
if _remainingRange > 0
setAirco: (_status) =>
= _status
'airco', _status
setOdo: (_status) =>
= Number _status
'odo', Number _status
setLocation: (_lat, _lon) =>
= _lat
= _lon
'lat', _lat
'lon', _lon
setCharge: (charging) =>
= Boolean charging
'charging', Boolean charging
#if charging
# = true # if charging, must be pluggedIn
# 'pluggedIn',
destroy:() =>
clearTimeout() if ?
.removeListener('clientReady', )
super()
class BluelinkActionProvider extends env.actions.ActionProvider
constructor: () ->
parseAction: (input, context) =>
bluelinkDevice = null
= null
supportedCarClasses = ["KiaDevice","HyundaiDevice"]
bluelinkDevices = _(.deviceManager.devices).values().filter(
(device) => device.config.class in supportedCarClasses
).value()
setCommand = (command) =>
= command
optionsString = (m,tokens) =>
unless tokens?
context?.addError("No variable")
return
= tokens
setCommand("start")
m = M(input, context)
.match('bluelink ')
.matchDevice(bluelinkDevices, (m, d) ->
# Already had a match with another device?
if bluelinkDevice? and bluelinkDevices.id isnt d.id
context?.addError(""""#{input.trim()}" is ambiguous.""")
return
bluelinkDevice = d
)
.or([
((m) =>
return m.match(' startDefault', (m) =>
setCommand('start')
match = m.getFullMatch()
)
),
((m) =>
return m.match(' start ')
.matchVariable(optionsString)
),
((m) =>
return m.match(' startPlus', (m) =>
setCommand('startPlus')
match = m.getFullMatch()
)
),
((m) =>
return m.match(' stop', (m) =>
setCommand('stop')
match = m.getFullMatch()
)
),
((m) =>
return m.match(' lock', (m) =>
setCommand('lock')
match = m.getFullMatch()
)
),
((m) =>
return m.match(' unlock', (m) =>
setCommand('unlock')
match = m.getFullMatch()
)
),
((m) =>
return m.match(' startCharge', (m) =>
setCommand('startCharge')
match = m.getFullMatch()
)
),
((m) =>
return m.match(' stopCharge', (m) =>
setCommand('stopCharge')
match = m.getFullMatch()
)
),
((m) =>
return m.match(' refresh', (m) =>
setCommand('refresh')
match = m.getFullMatch()
)
)
])
match = m.getFullMatch()
if match? #m.hadMatch()
env.logger.debug "Rule matched: '", match, "' and passed to Action handler"
return {
token: match
nextInput: input.substring(match.length)
actionHandler: new BluelinkActionHandler(, bluelinkDevice, , )
}
else
return null
class BluelinkActionHandler extends env.actions.ActionHandler
constructor: (, , , ) ->
executeAction: (simulate) =>
if simulate
return __("would have cleaned \"%s\"", "")
else
if ?
_var = .slice(1) if .indexOf('$') >= 0
_options = .variableManager.getVariableValue(_var)
unless _options?
return __("\"%s\" Rule not executed, #{_var} is not a valid variable", "")
else
_options = null
.execute(, _options)
.then(()=>
return __("\"%s\" Rule executed", )
).catch((err)=>
return __("\"%s\" Rule not executed", "")
)
class KiaDevice extends BluelinkDevice
class HyundaiDevice extends BluelinkDevice
bluelinkPlugin = new BluelinkPlugin
return bluelinkPlugin