diff --git a/README.md b/README.md index dd24204..4f62378 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Inside the ABRP (web)app, navigate to your car settings and use the "generic" ca #### 1.3 Adding the service to docker-compose.yml In your TeslaMate docker-compose.yml, add the teslamate-abrp service by adding the following lines in the "services:" section: -``` +```yaml ABRP: container_name: TeslaMate_ABRP image: fetzu/teslamate-abrp:latest #NOTE: you can replace ":latest" with ":beta" to use the bleeding edge version, without any guarantees. @@ -33,12 +33,20 @@ ABRP: ``` Make sure to adapt the following environment variables: + - The first value MQTT_SERVER corresponds to the name of your MQTT service name ("mosquitto" in the doc). - The second values (USER_TOKEN) correspond to the value provided by ABRP. - The third value corresponds to your car number (1 if you only have a single car). - The last value corresponds to your car model. When this value is not set, the script will try to determine your car model automatically (this should work for Models S, X, 3 and Y with standard configs). __The detection is very bare-bones and will not take into account factors such as wheel type, heat pump, LFP battery. It is recommended you take a moment to find your car model on https://api.iternio.com/1/tlm/get_carmodels_list and use the corresponding key as a value for CAR_MODEL (e.g. "tesla:m3:20:bt37:heatpump" for a 2021 Model 3 LR).__ -- Additionally, MQTT_PASSWORD and/or MQTT_USERNAME can be set to use authentication on the MQTT server. Setting the environement variable TM2ABRP_DEBUG (to any value) sets logging level to DEBUG and give you a more verbose logging. - +- Additionally; + - MQTT_PASSWORD and/or MQTT_USERNAME: can be set to use authentication on the MQTT server. + - MQTT_TLS: will connect to the MQTT server with encryption, the server must have certificates configured. + - MQTT_PORT: is the port the MQTT server is listening on, defaults to 1883 and if you are using TLS this probably should be set to 8883 + - STATUS_TOPIC: can be set to a MQTT topic where status messages will be sent, write permissions will be needed for this specific topic. + - SKIP_LOCATION: If you don't want to share your location with ABPR (Iternio), give this environment variable a value and the lat and lon values will always be 0 + - TM2ABRP_DEBUG: set (to any value) sets logging level to DEBUG and give you a more verbose logging. + + Then from the command line, navigate to the folder where your docker-compose.yml is located and run: ``` docker-compose pull ABRP @@ -47,6 +55,53 @@ docker-compose up -d ABRP If all goes well, your car should be shown as online in ABRP after a minute. Logging should show "YYYY-MM-DD HH:MM:SS: [INFO] Connected with result code 0. Connection with MQTT server established.". +#### 1.4 Security + +If you want to follow dockers recommendations regarding secrets, you should not provide them as `ENVIRONMENT VARIABLE` or command line parameters and instead use the build in [secrets function](https://docs.docker.com/compose/use-secrets/). This will expose the secrets in the container to the file system at `/run/secrets/`. Read the documentation carefully if you wish to use docker's secrets feature. + +This is an example of a part of a docker-compose.yml file using: + +- Secrets instead of environment variables +- TLS enabled on the MQTT Server +- A status topic provided +- Flag set not to send latitude and longitude information to ABRP +- Debug level logging activated + +```yaml +version: "3" +services: + abrp: + container_name: TeslaMate_ABRP + image: fetzu/teslamate-abrp:latest #NOTE: you can replace ":latest" with ":beta" to use the bleeding edge version, without any guarantees. + restart: always + environment: + CAR_NUMBER: 1 + MQTT_SERVER: your.server.tld # Replace with your actually server name + MQTT_PORT: 8883 # This is a TLS enabled server, and usually that is enabled on a different port than the default 1883 + MQTT_USERNAME: myMQTTusername # Replace with your actually mqtt username + MQTT_TLS: True # Connect to the MQTT server encrypted + STATUS_TOPIC: teslamate-abrp # This will send status messages and a copy of the ABRP data to the topic "teslamate-abrp/xxx" + TM2ABRP_DEBUG: True # This will enable debug level logging + SKIP_LOCATION: True # Don't send location info to ABRP + TZ: "Europe/Stockholm" + secrets: + - USER_TOKEN # Instead of having your token in clear text, it's found in the file below + - MQTT_PASSWORD # Instead of having your password in clear text, it's found in the file below + +secrets: +# These text files contains the token/passwords, and nothing else. +# They can be placed "anywhere" on the host system and protected by appropriate file permissions. + USER_TOKEN: + file: ./path/to/abrp-token.txt + MQTT_PASSWORD: + file: ./path/to/abrp-mqtt-pass.txt +``` + +To run it: +```bash +docker compose up -d +``` + ### 2. Use as python script The script can also be run directly on a machine with Python 3.x. Please note that the machine needs to have access to your MQTT server on port 1883. diff --git a/teslamate_mqtt2abrp.py b/teslamate_mqtt2abrp.py index 4e3e934..0320e0e 100644 --- a/teslamate_mqtt2abrp.py +++ b/teslamate_mqtt2abrp.py @@ -2,21 +2,25 @@ """TeslaMate MQTT to ABRP Usage: - teslamate_mqtt2abrp.py [-hdlap] [USER_TOKEN] [CAR_NUMBER] [MQTT_SERVER] [MQTT_USERNAME] [MQTT_PASSWORD] [--model CAR_MODEL] + teslamate_mqtt2abrp.py [-hdlasx] [USER_TOKEN] [CAR_NUMBER] [MQTT_SERVER] [MQTT_USERNAME] [MQTT_PASSWORD] [MQTT_PORT] [--model CAR_MODEL] [--status_topic TOPIC] Arguments: - USER_TOKEN User token generated by ABRP. - CAR_NUMBER Car number from TeslaMate (usually 1). - MQTT_SERVER MQTT server address (e.g. "192.168.1.1"). - MQTT_USERNAME MQTT username (e.g. "teslamate") - use with -l or -a. - MQTT_PASSWORD MQTT password (e.g. "etamalset") - use with -a. + USER_TOKEN User token generated by ABRP. + CAR_NUMBER Car number from TeslaMate (usually 1). + MQTT_SERVER MQTT server address (e.g. "192.168.1.1"). + MQTT_PORT MQTT port (e.g. 1883 or 8883 for TLS). + MQTT_USERNAME MQTT username (e.g. "teslamate") - use with -l or -a. + MQTT_PASSWORD MQTT password (e.g. "etamalset") - use with -a. Options: - -h Show this screen. - -d Debug mode (set logging level to DEBUG) - -l Use username to connect to MQTT server. - -a Use authentification (user and password) to connect to MQTT server. - --model CAR_MODEL Car model according to https://api.iternio.com/1/tlm/get_CARMODELs_list + -h Show this screen. + -d Debug mode (set logging level to DEBUG) + -l Use username to connect to MQTT server. + -a Use authentication (user and password) to connect to MQTT server. + -s Use TLS to connect to MQTT server, environment variable: MQTT_TLS + -x Don't send LAT and LON to ABRP, environment variable: SKIP_LOCATION + --model CAR_MODEL Car model according to https://api.iternio.com/1/tlm/get_CARMODELs_list + --status_topic TOPIC MQTT topic to publish status messages to, if not set, no publish will be done. Note: All arguments can also be passed as corresponding OS environment variables. @@ -33,7 +37,7 @@ from time import sleep from docopt import docopt -# Needed to intitialize docopt (for CLI) +# Needed to initialize docopt (for CLI) if __name__ == '__main__': arguments = docopt(__doc__) @@ -42,6 +46,33 @@ MQTTUSERNAME = None MQTTPASSWORD = None +## [ HELPER FUNCTIONS ] +def getDockerSecret(secretName): + file = "/run/secrets/"+secretName + if os.path.isfile(file): + fo = open(file,"r") + sec = fo.read().splitlines()[0] + if len(sec) > 0: + return sec + else: + return None + else: + return None + +def publish_to_mqtt(dataObject): + logging.debug("Publishing to MQTT: {}".format(dataObject)) + for key, value in dataObject.items(): + client.publish( + "{}/{}".format(BASETOPIC, key), + payload=value, + qos=1, + retain=True + ) + +def niceNow(): + return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d %H:%M:%S") + +## [ PARAMETERS ] if arguments['-d'] is True or 'TM2ABRP_DEBUG' in os.environ: log_level = logging.DEBUG else: log_level = logging.INFO @@ -57,14 +88,24 @@ if arguments['-a'] is True and arguments['MQTT_PASSWORD'] is not None: MQTTPASSWORD = arguments['MQTT_PASSWORD'] elif 'MQTT_PASSWORD' in os.environ: MQTTPASSWORD = os.environ['MQTT_PASSWORD'] +elif getDockerSecret("MQTT_PASSWORD") is not None: MQTTPASSWORD = getDockerSecret("MQTT_PASSWORD") if arguments['MQTT_SERVER'] is not None: MQTTSERVER = arguments['MQTT_SERVER'] elif 'MQTT_SERVER' in os.environ: MQTTSERVER = os.environ['MQTT_SERVER'] else: sys.exit("MQTT server address not supplied. Please supply through ENV variables or CLI argument.") +if arguments['MQTT_PORT'] is not None: MQTTPORT = int(arguments['MQTT_PORT']) +elif 'MQTT_PORT' in os.environ: MQTTPORT = int(os.environ['MQTT_PORT']) +else: + MQTTPORT = 1883 + +if arguments['-s'] is True or 'MQTT_TLS' in os.environ: + MQTTTLS = True + if arguments['USER_TOKEN'] is not None: USERTOKEN = arguments['USER_TOKEN'] elif 'USER_TOKEN' in os.environ: USERTOKEN = os.environ['USER_TOKEN'] +elif getDockerSecret('USER_TOKEN') is not None: USERTOKEN = getDockerSecret('USER_TOKEN') else: sys.exit("User token not supplied. Please generate it through ABRP and supply through ENV variables or CLI argument.") @@ -80,11 +121,21 @@ else: CARMODEL = None else: CARMODEL = arguments['--model'] +# Log to MQTT topic if specified +if arguments['--status_topic'] is not None: BASETOPIC = arguments['--status_topic'] +elif 'STATUS_TOPIC' in os.environ: BASETOPIC = os.environ['STATUS_TOPIC'] +else: BASETOPIC = None + +# Skip LAT and LON if specified +if arguments['-x'] is True or 'SKIP_LOCATION' in os.environ: SKIPLOCATION = True +else: SKIPLOCATION = False + ## [ VARS ] state = "" #car state prev_state = "" #car state previous loop for tracking -str_now = "" #create a global str_now variable to be used in logging/stdout charger_phases = 1 +prefix = "_tm2abrp" +state_topic = BASETOPIC + "/" + prefix + "_status" # MQTT topic to publish status messages to data = { #dictionary of values sent to ABRP API "utc": 0, "soc": 0, @@ -114,22 +165,27 @@ client = mqtt.Client(f"teslamateToABRP-{CARNUMBER}") if MQTTUSERNAME is not None: if MQTTPASSWORD is not None: + logging.debug("Using MQTT username: {} and password '******'".format(MQTTUSERNAME)) client.username_pw_set(MQTTUSERNAME, MQTTPASSWORD) else: + logging.debug("Using MQTT username: {}".format(MQTTUSERNAME)) client.username_pw_set(MQTTUSERNAME) - -client.connect(MQTTSERVER) +if MQTTTLS: + logging.debug("Using TLS with MQTT") + client.tls_set() +if BASETOPIC is not None: + logging.debug("Using MQTT base topic: {} for last will".format(BASETOPIC)) + client.will_set(state_topic, payload="offline", qos=2, retain=True) +logging.debug("Trying to connect to {}:{}".format(MQTTSERVER,MQTTPORT)) +client.connect(MQTTSERVER, MQTTPORT) def on_connect(client, userdata, flags, rc): # The callback for when the client connects to the broker # MQTT Error handling - if rc == 0: logging.info("Connected with result code {0}. Connection with MQTT server established.".format(str(rc))) - elif rc == 1: sys.exit("Connection to MQTT server refused: invalid protocol version.") - elif rc == 2: sys.exit("Connection to MQTT server refused: invalid client identifier.") - elif rc == 3: sys.exit("Connection to MQTT server refused: server unavailable.") - elif rc == 4: sys.exit("Connection to MQTT server refused: bad username or password. Check your credentials.") - elif rc == 5: sys.exit("Connection to MQTT server refused: not authorised. Provide username and password as needed.") - elif rc >= 6 and rc <= 255: sys.exit("Connection to MQTT server refused: unknown reason. Seems like you are really unlucky today :(.") + logging.info("MQTT Connection returned result: {} CODE {}".format(mqtt.connack_string(rc),rc)) + if rc != 0: + sys.exit("Could not connect") client.subscribe(f"teslamate/cars/{CARNUMBER}/#") + if BASETOPIC is not None: client.publish(state_topic, payload="online", qos=2, retain=True) # Process MQTT messages def on_message(client, userdata, message): @@ -140,92 +196,87 @@ def on_message(client, userdata, message): #extracts message data from the received message payload = str(message.payload.decode("utf-8")) - #updates the received data - topic_postfix = message.topic.split('/')[-1] - - if topic_postfix == "plugged_in": - a=1#noop - elif topic_postfix == "model": - data["model"] = payload - elif topic_postfix == "trim_badging": - data["trim_badging"] = payload - elif topic_postfix == "latitude": - data["lat"] = float(payload) - elif topic_postfix == "longitude": - data["lon"] = float(payload) - elif topic_postfix == "elevation": - data["elevation"] = int(payload) - elif topic_postfix == "speed": - data["speed"] = int(payload) - elif topic_postfix == "power": - data["power"] = float(payload) - if(data["is_charging"] == True and int(payload)<-11): - data["is_dcfc"] = True - elif topic_postfix == "charger_power": - if(payload!='' and int(payload)!=0): - data["is_charging"] = True - if int(payload)>11: + match (message.topic.split('/')[-1]): + case "model": + data["model"] = payload + case "trim_badging": + data["trim_badging"] = payload + case "latitude": + if not SKIPLOCATION: + data["lat"] = float(payload) + case "longitude": + if not SKIPLOCATION: + data["lon"] = float(payload) + case "elevation": + data["elevation"] = int(payload) + case "speed": + data["speed"] = int(payload) + case "power": + data["power"] = float(payload) + if(data["is_charging"] == True and int(payload)<-11): data["is_dcfc"] = True - elif topic_postfix == "heading": - data["heading"] = int(payload) - elif topic_postfix == "outside_temp": - data["ext_temp"] = float(payload) - elif topic_postfix == "odometer": - data["odometer"] = float(payload) - elif topic_postfix == "ideal_battery_range_km": - data["ideal_battery_range"] = float(payload) - elif topic_postfix == "est_battery_range_km": - data["est_battery_range"] = float(payload) - elif topic_postfix == "charger_actual_current": - if(payload!='' and int(payload) > 0): #charging, include current in message - data["current"] = int(payload) - else: - data["current"] = 0 - del data["current"] - elif topic_postfix == "charger_voltage": - if(payload!='' and int(payload) > 5): #charging, include voltage in message - data["voltage"] = int(payload) - else: - data["voltage"] = 0 - del data["voltage"] - elif topic_postfix == "shift_state": - if payload == "P": - data["is_parked"] = True - elif(payload == "D" or payload == "R"): - data["is_parked"] = False - elif topic_postfix == "state": - state = payload - if payload == "driving": - data["is_parked"] = False - data["is_charging"] = False - data["is_dcfc"] = False - elif payload == "charging": - data["is_parked"] = True - data["is_charging"] = True - data["is_dcfc"] = False - elif payload == "supercharging": - data["is_parked"] = True - data["is_charging"] = True - data["is_dcfc"] = True - elif(payload == "online" or payload == "suspended" or payload == "asleep"): - data["is_parked"] = True - data["is_charging"] = False - data["is_dcfc"] = False - elif topic_postfix == "usable_battery_level": #State of Charge of the vehicle (what's displayed on the dashboard of the vehicle is preferred) - data["soc"] = int(payload) - elif topic_postfix == "charge_energy_added": - data["kwh_charged"] = float(payload) - elif topic_postfix == "charger_phases": - charger_phases = 3 if payload and int(payload) > 1 else 1 #Fixes processing error when transitioning out of charging - elif topic_postfix == "inside_temp": - a=0 #Volontarely ignored - elif topic_postfix == "since": - a=0 #Volontarely ignored - else: - pass - logging.debug("Unneeded topic: {} {}".format(message.topic, payload)) - - # Calculate acurrate power on AC charging + case "charger_power": + if(payload != '' and int(payload)!=0): + data["is_charging"] = True + if int(payload)>11: + data["is_dcfc"] = True + case "heading": + data["heading"] = int(payload) + case "outside_temp": + data["ext_temp"] = float(payload) + case "odometer": + data["odometer"] = float(payload) + case "ideal_battery_range_km": + data["ideal_battery_range"] = float(payload) + case "est_battery_range_km": + data["est_battery_range"] = float(payload) + case "charger_actual_current": + if(payload != '' and int(payload) > 0): #charging, include current in message + data["current"] = int(payload) + else: + data["current"] = 0 + del data["current"] + case "charger_voltage": + if(payload != '' and int(payload) > 5): #charging, include voltage in message + data["voltage"] = int(payload) + else: + data["voltage"] = 0 + del data["voltage"] + case "shift_state": + if payload == "P": + data["is_parked"] = True + elif payload in ["D","R","N"]: + data["is_parked"] = False + case "state": + state = payload + if payload == "driving": + data["is_parked"] = False + data["is_charging"] = False + data["is_dcfc"] = False + elif payload == "charging": + data["is_parked"] = True + data["is_charging"] = True + data["is_dcfc"] = False + elif payload == "supercharging": + data["is_parked"] = True + data["is_charging"] = True + data["is_dcfc"] = True + elif payload in ["online", "suspended", "asleep"]: + data["is_parked"] = True + data["is_charging"] = False + data["is_dcfc"] = False + case "usable_battery_level": #State of Charge of the vehicle (what's displayed on the dashboard of the vehicle is preferred) + data["soc"] = int(payload) + case "charge_energy_added": + data["kwh_charged"] = float(payload) + case "charger_phases": + charger_phases = 3 if payload and int(payload) > 1 else 1 #Fixes processing error when transitioning out of charging + case _: + # Unhandled + logging.debug("Unneeded topic: {} {}".format(message.topic, payload)) + pass + + # Calculate accurate power on AC charging if data["is_charging"] == True and data["is_dcfc"] == False and "voltage" in data and "current" in data: data["power"] = float(data["current"] * data["voltage"] * charger_phases) / 1000.0 * -1 @@ -233,7 +284,7 @@ def on_message(client, userdata, message): except: logging.critical("Unexpected exception while processing message: {} {} {}".format(sys.exc_info()[0], message.topic, message.payload)) - + # Starts the MQTT loop processing messages client.on_message = on_message client.on_connect = on_connect # Define callback function for successful connection @@ -295,14 +346,23 @@ def updateABRP(): body = {"tlm": data} response = requests.post("https://api.iternio.com/1/tlm/send?token="+USERTOKEN, headers=headers, json=body) resp = response.json() + if BASETOPIC is not None: + publish_to_mqtt({"{}_post_last_status".format(prefix): resp["status"]}) if resp["status"] != "ok": logging.error("Error, response from the ABRP API: {}.".format(response.text)) + if BASETOPIC is not None: + publish_to_mqtt({"{}_post_last_error".format(prefix): niceNow()}) else: logging.info("Data object successfully sent: {}".format(data)) + if BASETOPIC is not None: + publish_to_mqtt({"{}_post_last_success".format(prefix): niceNow()}) except Exception as ex: logging.critical("Unexpected exception while POSTing to ABRP API: {}".format(sys.exc_info()[0])) logging.debug("Error message from ABRP API POST request: {}".format(ex)) - + if BASETOPIC is not None: + publish_to_mqtt({"{}_post_exception".format(prefix): ex}) + publish_to_mqtt({"{}_post_last_exception".format(prefix): niceNow()}) + ## [ MAIN ] # Starts the forever loop updating ABRP i = -1 @@ -315,9 +375,7 @@ def updateABRP(): current_datetime = datetime.datetime.now(datetime.UTC) current_timetuple = current_datetime.timetuple() data["utc"] = calendar.timegm(current_timetuple) #utc timestamp must be in every message - str_now = current_datetime.strftime("%Y-%m-%d %H:%M:%S") -# msg = str_now + ": Car is " + state - if(state == "parked" or state == "online" or state == "suspended" or state=="asleep" or state=="offline"): #if parked, update every 30 cylces/seconds + if state in ["parked", "online", "suspended", "asleep", "offline"]: #if parked, update every 30 cycles/seconds if data["power"] != 0: #sometimes after charging the last power value is kept and not refreshed until the next drive or charge session. data["power"] = 0.0 if data["speed"] > 0: #sometimes after driving the last speed value is kept and not refreshed until the next drive or charge session. @@ -327,16 +385,19 @@ def updateABRP(): if(i%30==0 or i>30): if prev_state != state: logging.info("Car is sleeping, updating every 30s.") updateABRP() + if BASETOPIC is not None: publish_to_mqtt(data) i = 0 elif state == "charging": #if charging, update every 6 cycles/seconds if i%6==0: if prev_state != state: logging.info("Car is charging, updating every 6s.") updateABRP() + if BASETOPIC is not None: publish_to_mqtt(data) elif state == "driving": #if driving, update every cycle/second if prev_state != state: logging.info("Car is driving, updating every second.") updateABRP() + if BASETOPIC is not None: publish_to_mqtt(data) else: - logging.error("Car is in unkown state ({}), not sending any update to ABRP.".format(state)) + logging.error("Car is in unknown state ({}), not sending any update to ABRP.".format(state)) prev_state = state client.loop_stop()