forked from dracoventions/TWCManager
-
Notifications
You must be signed in to change notification settings - Fork 0
/
TWCManager.py
3340 lines (2988 loc) · 166 KB
/
TWCManager.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#! /usr/bin/python3
################################################################################
# Code and TWC protocol reverse engineering by Chris Dragon.
#
# Additional logs and hints provided by Teslamotorsclub.com users:
# TheNoOne, IanAmber, and twc.
# Thank you!
#
# For support and information, please read through this thread:
# https://teslamotorsclub.com/tmc/threads/new-wall-connector-load-sharing-protocol.72830
#
# Report bugs at https://github.com/cdragon/TWCManager/issues
#
# This software is released under the "Unlicense" model: http://unlicense.org
# This means source code and TWC protocol knowledge are released to the general
# public free for personal or commercial use. I hope the knowledge will be used
# to increase the use of green energy sources by controlling the time and power
# level of car charging.
#
# WARNING:
# Misuse of the protocol described in this software can direct a Tesla Wall
# Charger to supply more current to a car than the charger wiring was designed
# for. This will trip a circuit breaker or may start a fire in the unlikely
# event that the circuit breaker fails.
# This software was not written or designed with the benefit of information from
# Tesla and there is always a small possibility that some unforeseen aspect of
# its operation could damage a Tesla vehicle or a Tesla Wall Charger. All
# efforts have been made to avoid such damage and this software is in active use
# on the author's own vehicle and TWC.
#
# In short, USE THIS SOFTWARE AT YOUR OWN RISK.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#
# For more information, please visit http://unlicense.org
################################################################################
# What's TWCManager good for?
#
# This script (TWCManager) pretends to be a Tesla Wall Charger (TWC) set to
# master mode. When wired to the IN or OUT pins of real TWC units set to slave
# mode (rotary switch position F), TWCManager can tell them to limit car
# charging to any whole amp value between 5A and the max rating of the charger.
# Charging can also be stopped so the car goes to sleep.
#
# This level of control is useful for having TWCManager track the real-time
# availability of green energy sources and direct the slave TWCs to use near the
# exact amount of energy available. This saves energy compared to sending the
# green energy off to a battery for later car charging or off to the grid where
# some of it is lost in transmission.
#
# TWCManager can also be set up to only allow charging during certain hours,
# stop charging if a grid overload or "save power day" is detected, reduce
# charging on one TWC when a "more important" one is plugged in, or whatever
# else you might want to do.
#
# One thing TWCManager does not have direct access to is the battery charge
# percentage of each plugged-in car. There are hints on forums that some TWCs
# do report battery state, but we have yet to see a TWC send such a message.
# It's possible the feature exists in TWCs with newer firmware.
# This is unfortunate, but if you own a Tesla vehicle being charged, people have
# figured out how to get its charge state by contacting Tesla's servers using
# the same password you use in the Tesla phone app. Be very careful not to
# expose that password because it allows unlocking and starting the car.
################################################################################
# Overview of protocol TWCs use to load share
#
# A TWC set to slave mode (rotary switch position F) sends a linkready message
# every 10 seconds.
# The message contains a unique 4-byte id that identifies that particular slave
# as the sender of the message.
#
# A TWC set to master mode sees a linkready message. In response, it sends a
# heartbeat message containing the slave's 4-byte id as the intended recipient
# of the message.
# The master's 4-byte id is included as the sender of the message.
#
# Slave sees a heartbeat message from master directed to its unique 4-byte id
# and responds with its own heartbeat message containing the master's 4-byte id
# as the intended recipient of the message.
# The slave's 4-byte id is included as the sender of the message.
#
# Master sends a heartbeat to a slave around once per second and expects a
# response heartbeat from the slave.
# Slaves do not send heartbeats without seeing one from a master first. If
# heartbeats stop coming from master, slave resumes sending linkready every 10
# seconds.
# If slaves stop replying to heartbeats from master, master stops sending
# heartbeats after about 26 seconds.
#
# Heartbeat messages contain a data block used to negotiate the amount of power
# available to each slave and to the master.
# The first byte is a status indicating things like is TWC plugged in, does it
# want power, is there an error, etc.
# Next two bytes indicate the amount of power requested or the amount allowed in
# 0.01 amp increments.
# Next two bytes indicate the amount of power being used to charge the car, also in
# 0.01 amp increments.
# Remaining bytes always contain a value of 0.
import serial
import time
import re
import subprocess
import queue
import random
import math
import struct
import sys
import traceback
import sysv_ipc
import json
from datetime import datetime
import threading
from serial.threaded import ser
##########################
#
# Configuration parameters
#
# Most users will have only one ttyUSB adapter plugged in and the default value
# of '/dev/ttyUSB0' below will work. If not, run 'dmesg |grep ttyUSB' on the
# command line to find your rs485 adapter and put its ttyUSB# value in the
# parameter below.
# If you're using a non-USB adapter like an RS485 shield, the value may need to
# be something like '/dev/serial0'.
rs485Adapter = '/dev/ttyUSB0'
# Set wiringMaxAmpsAllTWCs to the maximum number of amps your charger wiring
# can handle. I default this to a low 6A which should be safe with the minimum
# standard of wiring in the areas of the world that I'm aware of.
# Most U.S. chargers will be wired to handle at least 40A and sometimes 80A,
# whereas EU chargers will handle at most 32A (using 3 AC lines instead of 2 so
# the total power they deliver is similar).
# Setting wiringMaxAmpsAllTWCs too high will trip the circuit breaker on your
# charger at best or START A FIRE if the circuit breaker malfunctions.
# Keep in mind that circuit breakers are designed to handle only 80% of their
# max power rating continuously, so if your charger has a 50A circuit breaker,
# put 50 * 0.8 = 40 here.
# 40 amp breaker * 0.8 = 32 here.
# 30 amp breaker * 0.8 = 24 here.
# 100 amp breaker * 0.8 = 80 here.
# IF YOU'RE NOT SURE WHAT TO PUT HERE, ASK THE ELECTRICIAN WHO INSTALLED YOUR
# CHARGER.
wiringMaxAmpsAllTWCs = 40
# If all your chargers share a single circuit breaker, set wiringMaxAmpsPerTWC
# to the same value as wiringMaxAmpsAllTWCs.
# Rarely, each TWC will be wired to its own circuit breaker. If you're
# absolutely sure your chargers each have a separate breaker, put the value of
# that breaker * 0.8 here, and put the sum of all breakers * 0.8 as the value of
# wiringMaxAmpsAllTWCs.
# For example, if you have two TWCs each with a 50A breaker, set
# wiringMaxAmpsPerTWC = 50 * 0.8 = 40 and wiringMaxAmpsAllTWCs = 40 + 40 = 80.
wiringMaxAmpsPerTWC = 40
# https://teslamotorsclub.com/tmc/threads/model-s-gen2-charger-efficiency-testing.78740/#post-1844789
# says you're using 10.85% more power (91.75/82.77=1.1085) charging at 5A vs 40A,
# 2.48% more power at 10A vs 40A, and 1.9% more power at 20A vs 40A. This is
# using a car with 2nd generation onboard AC/DC converter (VINs ending in 20000
# and higher).
# https://teslamotorsclub.com/tmc/threads/higher-amp-charging-is-more-efficient.24972/
# says that cars using a 1st generation charger may use up to 30% more power
# at 6A vs 40A! However, the data refers to 120V 12A charging vs 240V 40A
# charging. 120V 12A is technically the same power as 240V 6A, but the car
# batteries need 400V DC to charge and a lot more power is wasted converting
# 120V AC to 400V DC than 240V AC to 400V DC.
#
# The main point is 6A charging wastes a lot of power, so we default to charging
# at a minimum of 12A by setting minAmpsPerTWC to 12. I picked 12A instead of 10A
# because there is a theory that multiples of 3A are most efficient, though I
# couldn't find any data showing that had been tested.
#
# Most EU chargers are connected to 230V, single-phase power which means 12A is
# about the same power as in US chargers. If you have three-phase power, you can
# lower minAmpsPerTWC to 6 and still be charging with more power than 12A on
# single-phase. For example, 12A * 230V * 1 = 2760W for single-phase power, while
# 6A * 230V * 3 = 4140W for three-phase power. Consult an electrician if this
# doesn't make sense.
#
# https://forums.tesla.com/forum/forums/charging-lowest-amperage-purposely
# says another reason to charge at higher power is to preserve battery life.
# The best charge rate is the capacity of the battery pack / 2. Home chargers
# can't reach that rate, so charging as fast as your wiring supports is best
# from that standpoint. It's not clear how much damage charging at slower
# rates really does.
minAmpsPerTWC = 12
# After determining how much green energy is available for charging, we add
# greenEnergyAmpsOffset to the value. This is most often given a negative value
# equal to the average amount of power consumed by everything other than car
# charging. For example, if your house uses an average of 2.8A to power
# computers, lights, etc while you expect the car to be charging, set
# greenEnergyAmpsOffset = -2.8.
#
# If you have solar panels, look at your utility meter while your car charges.
# If it says you're using 0.67kW, that means you should set
# greenEnergyAmpsOffset = -0.67kW * 1000 / 240V = -2.79A assuming you're on the
# North American 240V grid. In other words, during car charging, you want your
# utility meter to show a value close to 0kW meaning no energy is being sent to
# or from the grid.
greenEnergyAmpsOffset = 0
# Choose how much debugging info to output.
# 0 is no output other than errors.
# 1 is just the most useful info.
# 2-8 add debugging info
# 9 includes raw RS-485 messages transmitted and received (2-3 per sec)
# 10 is all info.
# 11 is more than all info. ;)
debugLevel = 1
# Choose whether to display milliseconds after time on each line of debug info.
displayMilliseconds = False
# Normally we fake being a TWC Master using fakeMaster = 1.
# Two other settings are available, but are only useful for debugging and
# experimenting:
# Set fakeMaster = 0 to fake being a TWC Slave instead of Master.
# Set fakeMaster = 2 to display received RS-485 messages but not send any
# unless you use the debugging web interface
# (index.php?debugTWC=1) to send messages.
fakeMaster = 1
# TWC's rs485 port runs at 9600 baud which has been verified with an
# oscilloscope. Don't change this unless something changes in future hardware.
baud = 9600
# All TWCs ship with a random two-byte TWCID. We default to using 0x7777 as our
# fake TWC ID. There is a 1 in 64535 chance that this ID will match each real
# TWC on the network, in which case you should pick a different random id below.
# This isn't really too important because even if this ID matches another TWC on
# the network, that TWC will pick its own new random ID as soon as it sees ours
# conflicts.
fakeTWCID = bytearray(b'\x77\x77')
# TWCs send a seemingly-random byte after their 2-byte TWC id in a number of
# messages. I call this byte their "Sign" for lack of a better term. The byte
# never changes unless the TWC is reset or power cycled. We use hard-coded
# values for now because I don't know if there are any rules to what values can
# be chosen. I picked 77 because it's easy to recognize when looking at logs.
# These shouldn't need to be changed.
masterSign = bytearray(b'\x77')
slaveSign = bytearray(b'\x77')
#
# End configuration parameters
#
##############################
##############################
#
# Begin functions
#
def time_now():
global displayMilliseconds
return(datetime.now().strftime("%H:%M:%S" + (
".%f" if displayMilliseconds else "")))
def hex_str(s:str):
return " ".join("{:02X}".format(ord(c)) for c in s)
def hex_str(ba:bytearray):
return " ".join("{:02X}".format(c) for c in ba)
def run_process(cmd):
result = None
try:
result = subprocess.check_output(cmd, shell=True)
except subprocess.CalledProcessError:
# We reach this point if the process returns a non-zero exit code.
result = b''
return result
def load_settings():
global debugLevel, settingsFileName, nonScheduledAmpsMax, scheduledAmpsMax, \
scheduledAmpsStartHour, scheduledAmpsEndHour, \
scheduledAmpsDaysBitmap, hourResumeTrackGreenEnergy, kWhDelivered, \
carApiBearerToken, carApiRefreshToken, carApiTokenExpireTime, \
homeLat, homeLon
try:
fh = open(settingsFileName, 'r')
for line in fh:
m = re.search(r'^\s*nonScheduledAmpsMax\s*=\s*([-0-9.]+)', line, re.MULTILINE)
if(m):
nonScheduledAmpsMax = int(m.group(1))
if(debugLevel >= 10):
print("load_settings: nonScheduledAmpsMax set to " + str(nonScheduledAmpsMax))
continue
m = re.search(r'^\s*scheduledAmpsMax\s*=\s*([-0-9.]+)', line, re.MULTILINE)
if(m):
scheduledAmpsMax = int(m.group(1))
if(debugLevel >= 10):
print("load_settings: scheduledAmpsMax set to " + str(scheduledAmpsMax))
continue
m = re.search(r'^\s*scheduledAmpsStartHour\s*=\s*([-0-9.]+)', line, re.MULTILINE)
if(m):
scheduledAmpsStartHour = float(m.group(1))
if(debugLevel >= 10):
print("load_settings: scheduledAmpsStartHour set to " + str(scheduledAmpsStartHour))
continue
m = re.search(r'^\s*scheduledAmpsEndHour\s*=\s*([-0-9.]+)', line, re.MULTILINE)
if(m):
scheduledAmpsEndHour = float(m.group(1))
if(debugLevel >= 10):
print("load_settings: scheduledAmpsEndHour set to " + str(scheduledAmpsEndHour))
continue
m = re.search(r'^\s*scheduledAmpsDaysBitmap\s*=\s*([-0-9.]+)', line, re.MULTILINE)
if(m):
scheduledAmpsDaysBitmap = int(m.group(1))
if(debugLevel >= 10):
print("load_settings: scheduledAmpsDaysBitmap set to " + str(scheduledAmpsDaysBitmap))
continue
m = re.search(r'^\s*hourResumeTrackGreenEnergy\s*=\s*([-0-9.]+)', line, re.MULTILINE)
if(m):
hourResumeTrackGreenEnergy = float(m.group(1))
if(debugLevel >= 10):
print("load_settings: hourResumeTrackGreenEnergy set to " + str(hourResumeTrackGreenEnergy))
continue
m = re.search(r'^\s*kWhDelivered\s*=\s*([-0-9.]+)', line, re.MULTILINE)
if(m):
kWhDelivered = float(m.group(1))
if(debugLevel >= 10):
print("load_settings: kWhDelivered set to " + str(kWhDelivered))
continue
m = re.search(r'^\s*carApiBearerToken\s*=\s*(.+)', line, re.MULTILINE)
if(m):
carApiBearerToken = m.group(1)
if(debugLevel >= 10):
print("load_settings: carApiBearerToken set to " + str(carApiBearerToken))
continue
m = re.search(r'^\s*carApiRefreshToken\s*=\s*(.+)', line, re.MULTILINE)
if(m):
carApiRefreshToken = m.group(1)
if(debugLevel >= 10):
print("load_settings: carApiRefreshToken set to " + str(carApiRefreshToken))
continue
m = re.search(r'^\s*carApiTokenExpireTime\s*=\s*(.+)', line, re.MULTILINE)
if(m):
carApiTokenExpireTime = float(m.group(1))
if(debugLevel >= 10):
print("load_settings: carApiTokenExpireTime set to " + str(carApiTokenExpireTime))
continue
m = re.search(r'^\s*homeLat\s*=\s*(.+)', line, re.MULTILINE)
if(m):
homeLat = float(m.group(1))
if(debugLevel >= 10):
print("load_settings: homeLat set to " + str(homeLat))
continue
m = re.search(r'^\s*homeLon\s*=\s*(.+)', line, re.MULTILINE)
if(m):
homeLon = float(m.group(1))
if(debugLevel >= 10):
print("load_settings: homeLon set to " + str(homeLon))
continue
print(time_now() + ": load_settings: Unknown setting " + line)
fh.close()
except FileNotFoundError:
pass
def save_settings():
global debugLevel, settingsFileName, nonScheduledAmpsMax, scheduledAmpsMax, \
scheduledAmpsStartHour, scheduledAmpsEndHour, \
scheduledAmpsDaysBitmap, hourResumeTrackGreenEnergy, kWhDelivered, \
carApiBearerToken, carApiRefreshToken, carApiTokenExpireTime, \
homeLat, homeLon
fh = open(settingsFileName, 'w')
fh.write('nonScheduledAmpsMax=' + str(nonScheduledAmpsMax) +
'\nscheduledAmpsMax=' + str(scheduledAmpsMax) +
'\nscheduledAmpsStartHour=' + str(scheduledAmpsStartHour) +
'\nscheduledAmpsEndHour=' + str(scheduledAmpsEndHour) +
'\nscheduledAmpsDaysBitmap=' + str(scheduledAmpsDaysBitmap) +
'\nhourResumeTrackGreenEnergy=' + str(hourResumeTrackGreenEnergy) +
'\nkWhDelivered=' + str(kWhDelivered) +
'\ncarApiBearerToken=' + str(carApiBearerToken) +
'\ncarApiRefreshToken=' + str(carApiRefreshToken) +
'\ncarApiTokenExpireTime=' + str(int(carApiTokenExpireTime)) +
'\nhomeLat=' + str(homeLat) +
'\nhomeLon=' + str(homeLon)
)
fh.close()
def trim_pad(s:bytearray, makeLen):
# Trim or pad s with zeros so that it's makeLen length.
while(len(s) < makeLen):
s += b'\x00'
if(len(s) > makeLen):
s = s[0:makeLen]
return s
def send_msg(msg):
# Send msg on the RS485 network. We'll escape bytes with a special meaning,
# add a CRC byte to the message end, and add a C0 byte to the start and end
# to mark where it begins and ends.
global ser, timeLastTx, fakeMaster, slaveTWCRoundRobin
msg = bytearray(msg)
checksum = 0
for i in range(1, len(msg)):
checksum += msg[i]
msg.append(checksum & 0xFF)
# Escaping special chars:
# The protocol uses C0 to mark the start and end of the message. If a C0
# must appear within the message, it is 'escaped' by replacing it with
# DB and DC bytes.
# A DB byte in the message is escaped by replacing it with DB DD.
#
# User FuzzyLogic found that this method of escaping and marking the start
# and end of messages is based on the SLIP protocol discussed here:
# https://en.wikipedia.org/wiki/Serial_Line_Internet_Protocol
i = 0
while(i < len(msg)):
if(msg[i] == 0xc0):
msg[i] = b'\xdb\xdc'
i = i + 1
elif(msg[i] == 0xdb):
msg[i] = b'\xdb\xdd'
i = i + 1
i = i + 1
msg = bytearray(b'\xc0' + msg + b'\xc0')
if(debugLevel >= 9):
print("Tx@" + time_now() + ": " + hex_str(msg))
ser.write(msg)
timeLastTx = time.time()
def unescape_msg(msg:bytearray, msgLen):
# Given a message received on the RS485 network, remove leading and trailing
# C0 byte, unescape special byte values, and verify its data matches the CRC
# byte.
msg = msg[0:msgLen]
# See notes in send_msg() for the way certain bytes in messages are escaped.
# We basically want to change db dc into c0 and db dd into db.
# Only scan to one less than the length of the string to avoid running off
# the end looking at i+1.
i = 0
while i < len(msg):
if(msg[i] == 0xdb):
if(msg[i+1] == 0xdc):
# Replace characters at msg[i] and msg[i+1] with 0xc0,
# shortening the string by one character. In Python, msg[x:y]
# refers to a substring starting at x and ending immediately
# before y. y - x is the length of the substring.
msg[i:i+2] = [0xc0]
elif(msg[i+1] == 0xdd):
msg[i:i+2] = [0xdb]
else:
print(time_now(), "ERROR: Special character 0xDB in message is " \
"followed by invalid character 0x%02X. " \
"Message may be corrupted." %
(msg[i+1]))
# Replace the character with something even though it's probably
# not the right thing.
msg[i:i+2] = [0xdb]
i = i+1
# Remove leading and trailing C0 byte.
msg = msg[1:len(msg)-1]
return msg
def send_master_linkready1():
if(debugLevel >= 1):
print(time_now() + ": Send master linkready1")
# When master is powered on or reset, it sends 5 to 7 copies of this
# linkready1 message followed by 5 copies of linkready2 (I've never seen
# more or less than 5 of linkready2).
#
# This linkready1 message advertises master's TWCID to other slaves on the
# network.
# If a slave happens to have the same id as master, it will pick a new
# random TWCID. Other than that, slaves don't seem to respond to linkready1.
# linkready1 and linkready2 are identical except FC E1 is replaced by FB E2
# in bytes 2-3. Both messages will cause a slave to pick a new id if the
# slave's id conflicts with master.
# If a slave stops sending heartbeats for awhile, master may send a series
# of linkready1 and linkready2 messages in seemingly random order, which
# means they don't indicate any sort of startup state.
# linkready1 is not sent again after boot/reset unless a slave sends its
# linkready message.
# At that point, linkready1 message may start sending every 1-5 seconds, or
# it may not be sent at all.
# Behaviors I've seen:
# Not sent at all as long as slave keeps responding to heartbeat messages
# right from the start.
# If slave stops responding, then re-appears, linkready1 gets sent
# frequently.
# One other possible purpose of linkready1 and/or linkready2 is to trigger
# an error condition if two TWCs on the network transmit those messages.
# That means two TWCs have rotary switches setting them to master mode and
# they will both flash their red LED 4 times with top green light on if that
# happens.
# Also note that linkready1 starts with FC E1 which is similar to the FC D1
# message that masters send out every 4 hours when idle. Oddly, the FC D1
# message contains all zeros instead of the master's id, so it seems
# pointless.
# I also don't understand the purpose of having both linkready1 and
# linkready2 since only two or more linkready2 will provoke a response from
# a slave regardless of whether linkready1 was sent previously. Firmware
# trace shows that slaves do something somewhat complex when they receive
# linkready1 but I haven't been curious enough to try to understand what
# they're doing. Tests show neither linkready1 or 2 are necessary. Slaves
# send slave linkready every 10 seconds whether or not they got master
# linkready1/2 and if a master sees slave linkready, it will start sending
# the slave master heartbeat once per second and the two are then connected.
send_msg(bytearray(b'\xFC\xE1') + fakeTWCID + masterSign + bytearray(b'\x00\x00\x00\x00\x00\x00\x00\x00'))
def send_master_linkready2():
if(debugLevel >= 1):
print(time_now() + ": Send master linkready2")
# This linkready2 message is also sent 5 times when master is booted/reset
# and then not sent again if no other TWCs are heard from on the network.
# If the master has ever seen a slave on the network, linkready2 is sent at
# long intervals.
# Slaves always ignore the first linkready2, but respond to the second
# linkready2 around 0.2s later by sending five slave linkready messages.
#
# It may be that this linkready2 message that sends FB E2 and the master
# heartbeat that sends fb e0 message are really the same, (same FB byte
# which I think is message type) except the E0 version includes the TWC ID
# of the slave the message is intended for whereas the E2 version has no
# recipient TWC ID.
#
# Once a master starts sending heartbeat messages to a slave, it
# no longer sends the global linkready2 message (or if it does,
# they're quite rare so I haven't seen them).
send_msg(bytearray(b'\xFB\xE2') + fakeTWCID + masterSign + bytearray(b'\x00\x00\x00\x00\x00\x00\x00\x00'))
def send_slave_linkready():
# In the message below, \x1F\x40 (hex 0x1f40 or 8000 in base 10) refers to
# this being a max 80.00Amp charger model.
# EU chargers are 32A and send 0x0c80 (3200 in base 10).
#
# I accidentally changed \x1f\x40 to \x2e\x69 at one point, which makes the
# master TWC immediately start blinking its red LED 6 times with top green
# LED on. Manual says this means "The networked Wall Connectors have
# different maximum current capabilities".
msg = bytearray(b'\xFD\xE2') + fakeTWCID + slaveSign + bytearray(b'\x1F\x40\x00\x00\x00\x00\x00\x00')
if(self.protocolVersion == 2):
msg += bytearray(b'\x00\x00')
send_msg(msg)
def master_id_conflict():
# We're playing fake slave, and we got a message from a master with our TWCID.
# By convention, as a slave we must change our TWCID because a master will not.
fakeTWCID[0] = random.randint(0, 0xFF)
fakeTWCID[1] = random.randint(0, 0xFF)
# Real slaves change their sign during a conflict, so we do too.
slaveSign[0] = random.randint(0, 0xFF)
print(time_now() + ": Master's TWCID matches our fake slave's TWCID. " \
"Picked new random TWCID %02X%02X with sign %02X" % \
(fakeTWCID[0], fakeTWCID[1], slaveSign[0]))
def new_slave(newSlaveID, maxAmps):
global slaveTWCs, slaveTWCRoundRobin
try:
slaveTWC = slaveTWCs[newSlaveID]
# We didn't get KeyError exception, so this slave is already in
# slaveTWCs and we can simply return it.
return slaveTWC
except KeyError:
pass
slaveTWC = TWCSlave(newSlaveID, maxAmps)
slaveTWCs[newSlaveID] = slaveTWC
slaveTWCRoundRobin.append(slaveTWC)
if(len(slaveTWCRoundRobin) > 3):
print("WARNING: More than 3 slave TWCs seen on network. " \
"Dropping oldest: " + hex_str(slaveTWCRoundRobin[0].TWCID) + ".")
delete_slave(slaveTWCRoundRobin[0].TWCID)
return slaveTWC
def delete_slave(deleteSlaveID):
global slaveTWCs, slaveTWCRoundRobin
for i in range(0, len(slaveTWCRoundRobin)):
if(slaveTWCRoundRobin[i].TWCID == deleteSlaveID):
del slaveTWCRoundRobin[i]
break
try:
del slaveTWCs[deleteSlaveID]
except KeyError:
pass
def slave_count():
global slaveTWCs
return len(slaveTWCs)
def is_slave_total_power_unsafe():
global debugLevel, slaveTWCRoundRobin, wiringMaxAmpsAllTWCs
totalAmps = 0
for slaveTWC in slaveTWCRoundRobin:
totalAmps += slaveTWC.lastAmpsOffered
if(debugLevel >= 10):
print("Total amps of all slaves: " + str(totalAmps))
if(totalAmps > wiringMaxAmpsAllTWCs):
return 1
return 0
def car_api_available(email = None, password = None, charge = None):
global debugLevel, carApiLastErrorTime, carApiErrorRetryMins, \
carApiTransientErrors, carApiBearerToken, carApiRefreshToken, \
carApiTokenExpireTime, carApiVehicles
now = time.time()
apiResponseDict = {}
if(now - carApiLastErrorTime < carApiErrorRetryMins*60):
# It's been under carApiErrorRetryMins minutes since the car API
# generated an error. To keep strain off Tesla's API servers, wait
# carApiErrorRetryMins mins till we try again. This delay could be
# reduced if you feel the need. It's mostly here to deal with unexpected
# errors that are hopefully transient.
# https://teslamotorsclub.com/tmc/threads/model-s-rest-api.13410/page-114#post-2732052
# says he tested hammering the servers with requests as fast as possible
# and was automatically blacklisted after 2 minutes. Waiting 30 mins was
# enough to clear the blacklist. So at this point it seems Tesla has
# accepted that third party apps use the API and deals with bad behavior
# automatically.
if(debugLevel >= 11):
print(time_now() + ': Car API disabled for ' +
str(int(carApiErrorRetryMins*60 - (now - carApiLastErrorTime))) +
' more seconds due to recent error.')
return False
# Tesla car API info comes from https://timdorr.docs.apiary.io/
if(carApiBearerToken == '' or carApiTokenExpireTime - now < 30*24*60*60):
cmd = None
apiResponse = b''
# If we don't have a bearer token or our refresh token will expire in
# under 30 days, get a new bearer token. Refresh tokens expire in 45
# days when first issued, so we'll get a new token every 15 days.
if(carApiRefreshToken != ''):
cmd = 'curl -s -m 60 -X POST -H "accept: application/json" -H "Content-Type: application/json" -d \'' + \
json.dumps({'grant_type': 'refresh_token', \
'client_id': '81527cff06843c8634fdc09e8ac0abefb46ac849f38fe1e431c2ef2106796384', \
'client_secret': 'c7257eb71a564034f9419ee651c7d0e5f7aa6bfbd18bafb5c5c033b093bb2fa3', \
'refresh_token': carApiRefreshToken }) + \
'\' "https://owner-api.teslamotors.com/oauth/token"'
elif(email != None and password != None):
cmd = 'curl -s -m 60 -X POST -H "accept: application/json" -H "Content-Type: application/json" -d \'' + \
json.dumps({'grant_type': 'password', \
'client_id': '81527cff06843c8634fdc09e8ac0abefb46ac849f38fe1e431c2ef2106796384', \
'client_secret': 'c7257eb71a564034f9419ee651c7d0e5f7aa6bfbd18bafb5c5c033b093bb2fa3', \
'email': email, 'password': password }) + \
'\' "https://owner-api.teslamotors.com/oauth/token"'
if(cmd != None):
if(debugLevel >= 2):
# Hide car password in output
cmdRedacted = re.sub(r'("password": )"[^"]+"', r'\1[HIDDEN]', cmd)
print(time_now() + ': Car API cmd', cmdRedacted)
apiResponse = run_process(cmd)
# Example response:
# b'{"access_token":"4720d5f980c9969b0ca77ab39399b9103adb63ee832014fe299684201929380","token_type":"bearer","expires_in":3888000,"refresh_token":"110dd4455437ed351649391a3425b411755a213aa815171a2c6bfea8cc1253ae","created_at":1525232970}'
try:
apiResponseDict = json.loads(apiResponse.decode('ascii'))
except json.decoder.JSONDecodeError:
pass
try:
if(debugLevel >= 4):
print(time_now() + ': Car API auth response', apiResponseDict, '\n')
carApiBearerToken = apiResponseDict['access_token']
carApiRefreshToken = apiResponseDict['refresh_token']
carApiTokenExpireTime = now + apiResponseDict['expires_in']
except KeyError:
print(time_now() + ": ERROR: Can't access Tesla car via API. Please log in again via web interface.")
carApiLastErrorTime = now
# Instead of just setting carApiLastErrorTime, erase tokens to
# prevent further authorization attempts until user enters password
# on web interface. I feel this is safer than trying to log in every
# ten minutes with a bad token because Tesla might decide to block
# remote access to your car after too many authorization errors.
carApiBearerToken = ''
carApiRefreshToken = ''
save_settings()
if(carApiBearerToken != ''):
if(len(carApiVehicles) < 1):
cmd = 'curl -s -m 60 -H "accept: application/json" -H "Authorization:Bearer ' + \
carApiBearerToken + \
'" "https://owner-api.teslamotors.com/api/1/vehicles"'
if(debugLevel >= 8):
print(time_now() + ': Car API cmd', cmd)
try:
apiResponseDict = json.loads(run_process(cmd).decode('ascii'))
except json.decoder.JSONDecodeError:
pass
try:
if(debugLevel >= 4):
print(time_now() + ': Car API vehicle list', apiResponseDict, '\n')
for i in range(0, apiResponseDict['count']):
carApiVehicles.append(CarApiVehicle(apiResponseDict['response'][i]['id']))
except (KeyError, TypeError):
# This catches cases like trying to access
# apiResponseDict['response'] when 'response' doesn't exist in
# apiResponseDict.
print(time_now() + ": ERROR: Can't get list of vehicles via Tesla car API. Will try again in "
+ str(carApiErrorRetryMins) + " minutes.")
carApiLastErrorTime = now
return False
if(len(carApiVehicles) > 0):
# Wake cars if needed
needSleep = False
for vehicle in carApiVehicles:
if(charge == True and vehicle.stopAskingToStartCharging):
if(debugLevel >= 8):
print(time_now() + ": Don't charge vehicle " + str(vehicle.ID)
+ " because vehicle.stopAskingToStartCharging == True")
continue
if(now - vehicle.lastErrorTime < carApiErrorRetryMins*60):
# It's been under carApiErrorRetryMins minutes since the car
# API generated an error on this vehicle. Don't send it more
# commands yet.
if(debugLevel >= 8):
print(time_now() + ": Don't send commands to vehicle " + str(vehicle.ID)
+ " because it returned an error in the last "
+ str(carApiErrorRetryMins) + " minutes.")
continue
if(vehicle.ready()):
continue
if(now - vehicle.lastWakeAttemptTime <= vehicle.delayNextWakeAttempt):
if(debugLevel >= 10):
print(time_now() + ": car_api_available returning False because we are still delaying "
+ str(delayNextWakeAttempt) + " seconds after the last failed wake attempt.")
return False
# It's been delayNextWakeAttempt seconds since we last failed to
# wake the car, or it's never been woken. Wake it.
vehicle.lastWakeAttemptTime = now
cmd = 'curl -s -m 60 -X POST -H "accept: application/json" -H "Authorization:Bearer ' + \
carApiBearerToken + \
'" "https://owner-api.teslamotors.com/api/1/vehicles/' + \
str(vehicle.ID) + '/wake_up"'
if(debugLevel >= 8):
print(time_now() + ': Car API cmd', cmd)
try:
apiResponseDict = json.loads(run_process(cmd).decode('ascii'))
except json.decoder.JSONDecodeError:
pass
state = 'error'
try:
if(debugLevel >= 4):
print(time_now() + ': Car API wake car response', apiResponseDict, '\n')
state = apiResponseDict['response']['state']
except (KeyError, TypeError):
# This catches unexpected cases like trying to access
# apiResponseDict['response'] when 'response' doesn't exist
# in apiResponseDict.
state = 'error'
if(state == 'online'):
# With max power saving settings, car will almost always
# report 'asleep' or 'offline' the first time it's sent
# wake_up. Rarely, it returns 'online' on the first wake_up
# even when the car has not been contacted in a long while.
# I suspect that happens when we happen to query the car
# when it periodically awakens for some reason.
vehicle.firstWakeAttemptTime = 0
vehicle.delayNextWakeAttempt = 0
# Don't alter vehicle.lastWakeAttemptTime because
# vehicle.ready() uses it to return True if the last wake
# was under 2 mins ago.
needSleep = True
else:
if(vehicle.firstWakeAttemptTime == 0):
vehicle.firstWakeAttemptTime = now
if(state == 'asleep' or state == 'waking'):
if(now - vehicle.firstWakeAttemptTime <= 10*60):
# http://visibletesla.com has a 'force wakeup' mode
# that sends wake_up messages once every 5 seconds
# 15 times. This generally manages to wake my car if
# it's returning 'asleep' state, but I don't think
# there is any reason for 5 seconds and 15 attempts.
# The car did wake in two tests with that timing,
# but on the third test, it had not entered online
# mode by the 15th wake_up and took another 10+
# seconds to come online. In general, I hear relays
# in the car clicking a few seconds after the first
# wake_up but the car does not enter 'waking' or
# 'online' state for a random period of time. I've
# seen it take over one minute, 20 sec.
#
# I interpret this to mean a car in 'asleep' mode is
# still receiving car API messages and will start
# to wake after the first wake_up, but it may take
# awhile to finish waking up. Therefore, we try
# waking every 30 seconds for the first 10 mins.
vehicle.delayNextWakeAttempt = 30;
elif(now - vehicle.firstWakeAttemptTime <= 70*60):
# Cars in 'asleep' state should wake within a
# couple minutes in my experience, so we should
# never reach this point. If we do, try every 5
# minutes for the next hour.
vehicle.delayNextWakeAttempt = 5*60;
else:
# Car hasn't woken for an hour and 10 mins. Try
# again in 15 minutes. We'll show an error about
# reaching this point later.
vehicle.delayNextWakeAttempt = 15*60;
elif(state == 'offline'):
if(now - vehicle.firstWakeAttemptTime <= 31*60):
# A car in offline state is presumably not connected
# wirelessly so our wake_up command will not reach
# it. Instead, the car wakes itself every 20-30
# minutes and waits some period of time for a
# message, then goes back to sleep. I'm not sure
# what the period of time is, so I tried sending
# wake_up every 55 seconds for 16 minutes but the
# car failed to wake.
# Next I tried once every 25 seconds for 31 mins.
# This worked after 19.5 and 19.75 minutes in 2
# tests but I can't be sure the car stays awake for
# 30secs or if I just happened to send a command
# during a shorter period of wakefulness.
vehicle.delayNextWakeAttempt = 25;
# I've run tests sending wake_up every 10-30 mins to
# a car in offline state and it will go hours
# without waking unless you're lucky enough to hit
# it in the brief time it's waiting for wireless
# commands. I assume cars only enter offline state
# when set to max power saving mode, and even then,
# they don't always enter the state even after 8
# hours of no API contact or other interaction. I've
# seen it remain in 'asleep' state when contacted
# after 16.5 hours, but I also think I've seen it in
# offline state after less than 16 hours, so I'm not
# sure what the rules are or if maybe Tesla contacts
# the car periodically which resets the offline
# countdown.
#
# I've also seen it enter 'offline' state a few
# minutes after finishing charging, then go 'online'
# on the third retry every 55 seconds. I suspect
# that might be a case of the car briefly losing
# wireless connection rather than actually going
# into a deep sleep.
# 'offline' may happen almost immediately if you
# don't have the charger plugged in.
else:
# Handle 'error' state.
if(now - vehicle.firstWakeAttemptTime <= 60*60):
# We've tried to wake the car for less than an
# hour.
foundKnownError = False
if('error' in apiResponseDict):
error = apiResponseDict['error']
for knownError in carApiTransientErrors:
if(knownError == error[0:len(knownError)]):
foundKnownError = True
break
if(foundKnownError):
# I see these errors often enough that I think
# it's worth re-trying in 1 minute rather than
# waiting 5 minutes for retry in the standard
# error handler.
vehicle.delayNextWakeAttempt = 60;
else:
# We're in an unexpected state. This could be caused
# by the API servers being down, car being out of
# range, or by something I can't anticipate. Try
# waking the car every 5 mins.
vehicle.delayNextWakeAttempt = 5*60;
else:
# Car hasn't woken for over an hour. Try again
# in 15 minutes. We'll show an error about this
# later.
vehicle.delayNextWakeAttempt = 15*60;
if(debugLevel >= 1):
if(state == 'error'):
print(time_now() + ": Car API wake car failed with unknown response. " \
"Will try again in "
+ str(vehicle.delayNextWakeAttempt) + " seconds.")
else:
print(time_now() + ": Car API wake car failed. State remains: '"
+ state + "'. Will try again in "
+ str(vehicle.delayNextWakeAttempt) + " seconds.")
if(vehicle.firstWakeAttemptTime > 0
and now - vehicle.firstWakeAttemptTime > 60*60):
# It should never take over an hour to wake a car. If it
# does, ask user to report an error.
print(time_now() + ": ERROR: We have failed to wake a car from '"
+ state + "' state for %.1f hours.\n" \
"Please private message user CDragon at " \
"http://teslamotorsclub.com with a copy of this error. " \
"Also include this: %s" % (
((now - vehicle.firstWakeAttemptTime) / 60 / 60),
str(apiResponseDict)))
if(now - carApiLastErrorTime < carApiErrorRetryMins*60 or carApiBearerToken == ''):
if(debugLevel >= 8):
print(time_now() + ": car_api_available returning False because of recent carApiLasterrorTime "
+ str(now - carApiLastErrorTime) + " or empty carApiBearerToken '"
+ carApiBearerToken + "'")
return False
if(debugLevel >= 8):
# We return True to indicate there was no error that prevents running
# car API commands and that we successfully got a list of vehicles.
# True does not indicate that any vehicle is actually awake and ready
# for commands.
print(time_now() + ": car_api_available returning True")
if(needSleep):
# If you send charge_start/stop less than 1 second after calling
# update_location(), the charge command usually returns:
# {'response': {'result': False, 'reason': 'could_not_wake_buses'}}
# I'm not sure if the same problem exists when sending commands too
# quickly after we send wake_up. I haven't seen a problem sending a
# command immediately, but it seems safest to sleep 5 seconds after
# waking before sending a command.
time.sleep(5);
return True
def car_api_charge(charge):
# Do not call this function directly. Call by using background thread:
# queue_background_task({'cmd':'charge', 'charge':<True/False>})
global debugLevel, carApiLastErrorTime, carApiErrorRetryMins, \
carApiTransientErrors, carApiVehicles, carApiLastStartOrStopChargeTime, \
homeLat, homeLon
now = time.time()
apiResponseDict = {}