forked from dracoventions/TWCManager
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathTWCManager.py
2555 lines (2298 loc) · 127 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
import paho.mqtt.client as mqtt
##########################
#
# 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 = 25
# 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 = 25
# 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 = 6
# 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')
# These values are used to store the amps received via MQTT
current1 = current2 = current3 = -1
p1_updated = datetime.min
headroom = 16 # safe value
#
# End configuration parameters
#
##############################
##############################
#
# Begin functions
#
def mqtt_init():
mqtt_broker = "127.0.0.1"
mqtt_p1_topic = "dsmr/json"
client = mqtt.Client("P1")
client.message_callback_add(mqtt_p1_topic,on_p1_message)
client.connect(mqtt_broker)
client.subscribe(mqtt_p1_topic)
client.loop_start()
def on_p1_message(client, userdata, msg):
global current1, current2, current3, p1_updated, headroom
m_decode=str(msg.payload.decode("utf-8","ignore"))
m=json.loads(m_decode)
current1 = m.get('phase_power_current_l1', current1)
current2 = m.get('phase_power_current_l2', current2)
current3 = m.get('phase_power_current_l3', current3)
p1_updated = datetime.now()
# Calculate the available amps by taking the grid max capacity, deduct current grid consumption and then add the amps used by the TWC's as an offset
totalAmpsAllTWCs = math.ceil(total_amps_actual_all_twcs())
headroom = int(wiringMaxAmpsAllTWCs - max(current1, current2, current3) + totalAmpsAllTWCs)
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
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
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
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)
)
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:i+1] = b'\xdb\xdc'
i = i + 1
elif(msg[i] == 0xdb):
msg[i:i+1] = 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 total_amps_actual_all_twcs():
global debugLevel, slaveTWCRoundRobin, wiringMaxAmpsAllTWCs
totalAmps = 0
for slaveTWC in slaveTWCRoundRobin:
totalAmps += slaveTWC.reportedAmpsActual
if(debugLevel >= 10):
print("Total amps all slaves are using: " + str(totalAmps))
return totalAmps
#
# End functions
#
##############################
##############################
#
# Begin TWCSlave class
#
class TWCSlave:
TWCID = None
maxAmps = None
# Protocol 2 TWCs tend to respond to commands sent using protocol 1, so
# default to that till we know for sure we're talking to protocol 2.
protocolVersion = 1
minAmpsTWCSupports = 6
masterHeartbeatData = bytearray(b'\x00\x00\x00\x00\x00\x00\x00\x00\x00')
timeLastRx = time.time()
# reported* vars below are reported to us in heartbeat messages from a Slave
# TWC.
reportedAmpsMax = 0
reportedAmpsActual = 0
reportedState = 0
# reportedAmpsActual frequently changes by small amounts, like 5.14A may
# frequently change to 5.23A and back.
# reportedAmpsActualSignificantChangeMonitor is set to reportedAmpsActual
# whenever reportedAmpsActual is at least 0.8A different than
# reportedAmpsActualSignificantChangeMonitor. Whenever
# reportedAmpsActualSignificantChangeMonitor is changed,
# timeReportedAmpsActualChangedSignificantly is set to the time of the
# change. The value of reportedAmpsActualSignificantChangeMonitor should not
# be used for any other purpose. timeReportedAmpsActualChangedSignificantly
# is used for things like preventing start and stop charge on a car more
# than once per minute.
reportedAmpsActualSignificantChangeMonitor = -1
timeReportedAmpsActualChangedSignificantly = time.time()
lastAmpsOffered = -1
timeLastAmpsOfferedChanged = time.time()
lastHeartbeatDebugOutput = ''
timeLastHeartbeatDebugOutput = 0
wiringMaxAmps = wiringMaxAmpsPerTWC
def __init__(self, TWCID, maxAmps):
self.TWCID = TWCID
self.maxAmps = maxAmps
def print_status(self, heartbeatData):
global fakeMaster, masterTWCID
try:
debugOutput = ": SHB %02X%02X: %02X %05.2f/%05.2fA %02X%02X" % \
(self.TWCID[0], self.TWCID[1], heartbeatData[0],
(((heartbeatData[3] << 8) + heartbeatData[4]) / 100),
(((heartbeatData[1] << 8) + heartbeatData[2]) / 100),
heartbeatData[5], heartbeatData[6]
)
if(self.protocolVersion == 2):
debugOutput += (" %02X%02X" % (heartbeatData[7], heartbeatData[8]))
debugOutput += " M"
if(not fakeMaster):
debugOutput += " %02X%02X" % (masterTWCID[0], masterTWCID[1])
debugOutput += ": %02X %05.2f/%05.2fA %02X%02X" % \
(self.masterHeartbeatData[0],
(((self.masterHeartbeatData[3] << 8) + self.masterHeartbeatData[4]) / 100),
(((self.masterHeartbeatData[1] << 8) + self.masterHeartbeatData[2]) / 100),
self.masterHeartbeatData[5], self.masterHeartbeatData[6])
if(self.protocolVersion == 2):
debugOutput += (" %02X%02X" %
(self.masterHeartbeatData[7], self.masterHeartbeatData[8]))
# Only output once-per-second heartbeat debug info when it's
# different from the last output or if the only change has been amps
# in use and it's only changed by 1.0 or less. Also output f it's
# been 10 mins since the last output or if debugLevel is turned up
# to 11.
lastAmpsUsed = 0
ampsUsed = 1
debugOutputCompare = debugOutput
m1 = re.search(r'SHB ....: .. (..\...)/', self.lastHeartbeatDebugOutput)
if(m1):
lastAmpsUsed = float(m1.group(1))
m2 = re.search(r'SHB ....: .. (..\...)/', debugOutput)
if(m2):
ampsUsed = float(m2.group(1))
if(m1):
debugOutputCompare = debugOutputCompare[0:m2.start(1)] + \
self.lastHeartbeatDebugOutput[m1.start(1):m1.end(1)] + \
debugOutputCompare[m2.end(1):]
if(
debugOutputCompare != self.lastHeartbeatDebugOutput
or abs(ampsUsed - lastAmpsUsed) >= 1.0
or time.time() - self.timeLastHeartbeatDebugOutput > 600
or debugLevel >= 11
):
print(time_now() + debugOutput)
self.lastHeartbeatDebugOutput = debugOutput
self.timeLastHeartbeatDebugOutput = time.time()
except IndexError:
# This happens if we try to access, say, heartbeatData[8] when
# len(heartbeatData) < 9. This was happening due to a bug I fixed
# but I may as well leave this here just in case.
if(len(heartbeatData) != (7 if self.protocolVersion == 1 else 9)):
print(time_now() + ': Error in print_status displaying heartbeatData',
heartbeatData, 'based on msg', hex_str(msg))
if(len(self.masterHeartbeatData) != (7 if self.protocolVersion == 1 else 9)):
print(time_now() + ': Error in print_status displaying masterHeartbeatData', self.masterHeartbeatData)
def send_slave_heartbeat(self, masterID):
# Send slave heartbeat
#
# Heartbeat includes data we store in slaveHeartbeatData.
# Meaning of data:
#
# Byte 1 is a state code:
# 00 Ready
# Car may or may not be plugged in.
# When car has reached its charge target, I've repeatedly seen it
# change from 03 to 00 the moment I wake the car using the phone app.
# 01 Plugged in, charging
# 02 Error
# This indicates an error such as not getting a heartbeat message
# from Master for too long.
# 03 Plugged in, do not charge
# I've seen this state briefly when plug is first inserted, and
# I've seen this state remain indefinitely after pressing stop
# charge on car's screen or when the car reaches its target charge
# percentage. Unfortunately, this state does not reliably remain
# set, so I don't think it can be used to tell when a car is done
# charging. It may also remain indefinitely if TWCManager script is
# stopped for too long while car is charging even after TWCManager
# is restarted. In that case, car will not charge even when start
# charge on screen is pressed - only re-plugging in charge cable
# fixes it.
# 04 Plugged in, ready to charge or charge scheduled
# I've seen this state even when car is set to charge at a future
# time via its UI. In that case, it won't accept power offered to
# it.
# 05 Busy?
# I've only seen it hit this state for 1 second at a time and it
# can seemingly happen during any other state. Maybe it means wait,
# I'm busy? Communicating with car?
# 08 Starting to charge?
# This state may remain for a few seconds while car ramps up from
# 0A to 1.3A, then state usually changes to 01. Sometimes car skips
# 08 and goes directly to 01.
# I saw 08 consistently each time I stopped fake master script with
# car scheduled to charge, plugged in, charge port blue. If the car
# is actually charging and you stop TWCManager, after 20-30 seconds
# the charge port turns solid red, steering wheel display says
# "charge cable fault", and main screen says "check charger power".
# When TWCManager is started, it sees this 08 status again. If we
# start TWCManager and send the slave a new max power value, 08
# becomes 00 and car starts charging again.
#
# Protocol 2 adds a number of other states:
# 06, 07, 09
# These are each sent as a response to Master sending the
# corresponding state. Ie if Master sends 06, slave responds with
# 06. See notes in send_master_heartbeat for meaning.
# 0A Amp adjustment period complete
# Master uses state 06 and 07 to raise or lower the slave by 2A
# temporarily. When that temporary period is over, it changes
# state to 0A.
# 0F was reported by another user but I've not seen it during testing
# and have no idea what it means.
#
# Byte 2-3 is the max current available as provided by bytes 2-3 in our
# fake master status.
# For example, if bytes 2-3 are 0F A0, combine them as 0x0fa0 hex which
# is 4000 in base 10. Move the decimal point two places left and you get
# 40.00Amps max.
#
# Byte 4-5 represents the power the car is actually drawing for
# charging. When a car is told to charge at 19A you may see a value like
# 07 28 which is 0x728 hex or 1832 in base 10. Move the decimal point
# two places left and you see the charger is using 18.32A.
# Some TWCs report 0A when a car is not charging while others may report
# small values such as 0.25A. I suspect 0A is what should be reported
# and any small value indicates a minor calibration error.
#
# Remaining bytes are always 00 00 from what I've seen and could be
# reserved for future use or may be used in a situation I've not
# observed. Protocol 1 uses two zero bytes while protocol 2 uses four.
###############################
# How was the above determined?
#
# An unplugged slave sends a status like this:
# 00 00 00 00 19 00 00
#
# A real master always sends all 00 status data to a slave reporting the
# above status. slaveHeartbeatData[0] is the main driver of how master
# responds, but whether slaveHeartbeatData[1] and [2] have 00 or non-00
# values also matters.
#
# I did a test with a protocol 1 TWC with fake slave sending
# slaveHeartbeatData[0] values from 00 to ff along with
# slaveHeartbeatData[1-2] of 00 and whatever
# value Master last responded with. I found:
# Slave sends: 04 00 00 00 19 00 00
# Master responds: 05 12 c0 00 00 00 00
#
# Slave sends: 04 12 c0 00 19 00 00
# Master responds: 00 00 00 00 00 00 00
#
# Slave sends: 08 00 00 00 19 00 00
# Master responds: 08 12 c0 00 00 00 00
#
# Slave sends: 08 12 c0 00 19 00 00
# Master responds: 00 00 00 00 00 00 00
#
# In other words, master always sends all 00 unless slave sends
# slaveHeartbeatData[0] 04 or 08 with slaveHeartbeatData[1-2] both 00.
#
# I interpret all this to mean that when slave sends
# slaveHeartbeatData[1-2] both 00, it's requesting a max power from
# master. Master responds by telling the slave how much power it can
# use. Once the slave is saying how much max power it's going to use
# (slaveHeartbeatData[1-2] = 12 c0 = 32.00A), master indicates that's
# fine by sending 00 00.
#
# However, if the master wants to set a lower limit on the slave, all it
# has to do is send any heartbeatData[1-2] value greater than 00 00 at
# any time and slave will respond by setting its
# slaveHeartbeatData[1-2] to the same value.
#
# I thought slave might be able to negotiate a lower value if, say, the
# car reported 40A was its max capability or if the slave itself could
# only handle 80A, but the slave dutifully responds with the same value
# master sends it even if that value is an insane 655.35A. I tested
# these values on car which has a 40A limit when AC charging and
# slave accepts them all:
# 0f aa (40.10A)
# 1f 40 (80.00A)
# 1f 41 (80.01A)
# ff ff (655.35A)
global fakeTWCID, slaveHeartbeatData, overrideMasterHeartbeatData
if(self.protocolVersion == 1 and len(slaveHeartbeatData) > 7):
# Cut array down to length 7
slaveHeartbeatData = slaveHeartbeatData[0:7]
elif(self.protocolVersion == 2):
while(len(slaveHeartbeatData) < 9):
# Increase array length to 9
slaveHeartbeatData.append(0x00)
send_msg(bytearray(b'\xFD\xE0') + fakeTWCID + bytearray(masterID) + bytearray(slaveHeartbeatData))
def send_master_heartbeat(self):
# Send our fake master's heartbeat to this TWCSlave.
#
# Heartbeat includes 7 bytes (Protocol 1) or 9 bytes (Protocol 2) of data
# that we store in masterHeartbeatData.
# Meaning of data:
#
# Byte 1 is a command:
# 00 Make no changes
# 02 Error
# Byte 2 appears to act as a bitmap where each set bit causes the
# slave TWC to enter a different error state. First 8 digits below
# show which bits are set and these values were tested on a Protocol
# 2 TWC:
# 0000 0001 = Middle LED blinks 3 times red, top LED solid green.
# Manual says this code means 'Incorrect rotary switch
# setting.'
# 0000 0010 = Middle LED blinks 5 times red, top LED solid green.
# Manual says this code means 'More than three Wall
# Connectors are set to Slave.'
# 0000 0100 = Middle LED blinks 6 times red, top LED solid green.
# Manual says this code means 'The networked Wall
# Connectors have different maximum current
# capabilities.'
# 0000 1000 = No effect
# 0001 0000 = No effect
# 0010 0000 = No effect
# 0100 0000 = No effect
# 1000 0000 = No effect
# When two bits are set, the lowest bit (rightmost bit) seems to
# take precedence (ie 111 results in 3 blinks, 110 results in 5
# blinks).
#
# If you send 02 to a slave TWC with an error code that triggers
# the middle LED to blink red, slave responds with 02 in its
# heartbeat, then stops sending heartbeat and refuses further
# communication. Slave's error state can be cleared by holding red
# reset button on its left side for about 4 seconds.
# If you send an error code with bitmap 11110xxx (where x is any bit),
# the error can not be cleared with a 4-second reset. Instead, you
# must power cycle the TWC or 'reboot' reset which means holding
# reset for about 6 seconds till all the LEDs turn green.
# 05 Tell slave charger to limit power to number of amps in bytes 2-3.
#
# Protocol 2 adds a few more command codes:
# 06 Increase charge current by 2 amps. Slave changes its heartbeat
# state to 06 in response. After 44 seconds, slave state changes to
# 0A but amp value doesn't change. This state seems to be used to
# safely creep up the amp value of a slave when the Master has extra
# power to distribute. If a slave is attached to a car that doesn't
# want that many amps, Master will see the car isn't accepting the
# amps and stop offering more. It's possible the 0A state change
# is not time based but rather indicates something like the car is
# now using as many amps as it's going to use.
# 07 Lower charge current by 2 amps. Slave changes its heartbeat state
# to 07 in response. After 10 seconds, slave raises its amp setting
# back up by 2A and changes state to 0A.
# I could be wrong, but when a real car doesn't want the higher amp
# value, I think the TWC doesn't raise by 2A after 10 seconds. Real
# Master TWCs seem to send 07 state to all children periodically as
# if to check if they're willing to accept lower amp values. If
# they do, Master assigns those amps to a different slave using the
# 06 state.
# 08 Master acknowledges that slave stopped charging (I think), but
# the next two bytes contain an amp value the slave could be using.
# 09 Tell slave charger to limit power to number of amps in bytes 2-3.
# This command replaces the 05 command in Protocol 1. However, 05
# continues to be used, but only to set an amp value to be used
# before a car starts charging. If 05 is sent after a car is
# already charging, it is ignored.
#
# Byte 2-3 is the max current a slave TWC can charge at in command codes
# 05, 08, and 09. In command code 02, byte 2 is a bitmap. With other
# command codes, bytes 2-3 are ignored.
# If bytes 2-3 are an amp value of 0F A0, combine them as 0x0fa0 hex
# which is 4000 in base 10. Move the decimal point two places left and
# you get 40.00Amps max.
#
# Byte 4: 01 when a Master TWC is physically plugged in to a car.
# Otherwise 00.
#
# Remaining bytes are always 00.
#
# Example 7-byte data that real masters have sent in Protocol 1:
# 00 00 00 00 00 00 00 (Idle)
# 02 04 00 00 00 00 00 (Error bitmap 04. This happened when I
# advertised a fake Master using an invalid max
# amp value)
# 05 0f a0 00 00 00 00 (Master telling slave to limit power to 0f a0
# (40.00A))
# 05 07 d0 01 00 00 00 (Master plugged in to a car and presumably
# telling slaves to limit power to 07 d0
# (20.00A). 01 byte indicates Master is plugged
# in to a car.)
global fakeTWCID, overrideMasterHeartbeatData, debugLevel, \
timeLastTx
if(len(overrideMasterHeartbeatData) >= 7):
self.masterHeartbeatData = overrideMasterHeartbeatData
# if(self.protocolVersion == 2):
# TODO: Start and stop charging using protocol 2 commands to TWC
# instead of car api if I ever figure out how.
# if(self.lastAmpsOffered == 0 and self.reportedAmpsActual > 4.0):
# Car is trying to charge, so stop it via car API.
# car_api_charge() will prevent telling the car to start or stop
# more than once per minute. Once the car gets the message to
# stop, reportedAmpsActualSignificantChangeMonitor should drop
# to near zero within a few seconds.
# WARNING: If you own two vehicles and one is charging at home but
# the other is charging away from home, this command will stop
# them both from charging. If the away vehicle is not currently
# charging, I'm not sure if this would prevent it from charging
# when next plugged in.
# Placeholder for start/stop code if we ever get it
# elif(self.lastAmpsOffered >= 5.0 and self.reportedAmpsActual < 2.0
# and self.reportedState != 0x02
# ):
# Car is not charging and is not reporting an error state, so
# try starting charge via car api.
# Placeholder for start/stop code if we ever get it