forked from ngardiner/TWCManager
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathTWCManager.py
executable file
·1507 lines (1359 loc) · 63.1 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/ngardiner/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.
#
# 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
import commentjson
import importlib
import json
import logging
import os.path
import math
import re
import sys
import time
import traceback
from datetime import datetime
import threading
from ww import f
from lib.TWCManager.TWCMaster import TWCMaster
import requests
from enum import Enum
logging.addLevelName(19, "INFO2")
logging.addLevelName(18, "INFO4")
logging.addLevelName(17, "INFO4")
logging.addLevelName(16, "INFO5")
logging.addLevelName(15, "INFO6")
logging.addLevelName(14, "INFO7")
logging.addLevelName(13, "INFO8")
logging.addLevelName(12, "INFO9")
logging.addLevelName(9, "DEBUG2")
logging.INFO2 = 19
logging.INFO3 = 18
logging.INFO4 = 17
logging.INFO5 = 16
logging.INFO6 = 15
logging.INFO7 = 14
logging.INFO8 = 13
logging.INFO9 = 12
logging.DEBUG2 = 9
logger = logging.getLogger("TWCManager")
# Define available modules for the instantiator
# All listed modules will be loaded at boot time
# Logging modules should be the first one to load
modules_available = [
"Logging.ConsoleLogging",
"Logging.FileLogging",
"Logging.SentryLogging",
"Logging.CSVLogging",
"Logging.MySQLLogging",
"Logging.SQLiteLogging",
"Protocol.TWCProtocol",
"Interface.Dummy",
"Interface.RS485",
"Interface.TCP",
"Policy.Policy",
"Vehicle.TeslaAPI",
"Control.WebIPCControl",
"Control.HTTPControl",
"Control.MQTTControl",
"Control.OCPPControl",
"EMS.Enphase",
"EMS.Fronius",
"EMS.HASS",
"EMS.Kostal",
"EMS.OpenHab",
"EMS.SmartMe",
"EMS.SolarEdge",
"EMS.SolarLog",
"EMS.TeslaPowerwall2",
"EMS.TED",
"EMS.Efergy",
"Status.HASSStatus",
"Status.MQTTStatus",
]
# Enable support for Python Visual Studio Debugger
if "DEBUG_SECRET" in os.environ:
import ptvsd
ptvsd.enable_attach(os.environ["DEBUG_SECRET"])
ptvsd.wait_for_attach()
##########################
# Load Configuration File
config = None
jsonconfig = None
if os.path.isfile("/etc/twcmanager/config.json"):
jsonconfig = open("/etc/twcmanager/config.json")
else:
if os.path.isfile("config.json"):
jsonconfig = open("config.json")
if jsonconfig:
config = commentjson.load(jsonconfig)
else:
logger.info("Unable to find a configuration file.")
sys.exit()
logLevel = config["config"].get("logLevel")
if logLevel == None:
debugLevel = config["config"].get("debugLevel", 1)
debug_to_log = {
0: 40,
1: 20,
2: 19,
3: 18,
4: 17,
5: 16,
6: 15,
7: 14,
8: 13,
9: 12,
10: 10,
11: 9,
}
for debug, log in debug_to_log.items():
if debug >= debugLevel:
logLevel = log
break
logging.getLogger().setLevel(logLevel)
# 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")
#
# End configuration parameters
#
##############################
##############################
#
# Begin functions
#
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 time_now():
global config
return datetime.now().strftime(
"%H:%M:%S" + (".%f" if config["config"]["displayMilliseconds"] else "")
)
def unescape_msg(inmsg: 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.
# Note that a bytearray is mutable, whereas a bytes object isn't.
# By initializing a bytearray and concatenating the incoming bytearray
# to it, we protect against being passed an immutable bytes object
msg = bytearray() + inmsg[0:msgLen]
# See notes in RS485.send() 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:
logger.info(
"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 background_tasks_thread(master):
carapi = master.getModuleByName("TeslaAPI")
while True:
try:
task = master.getBackgroundTask()
if task["cmd"] == "applyChargeLimit":
carapi.applyChargeLimit(limit=task["limit"])
elif task["cmd"] == "charge":
# car_api_charge does nothing if it's been under 60 secs since it
# was last used so we shouldn't have to worry about calling this
# too frequently.
carapi.car_api_charge(task["charge"])
elif task["cmd"] == "carApiEmailPassword":
carapi.setCarApiLastErrorTime(0)
carapi.car_api_available(task["email"], task["password"])
elif task["cmd"] == "checkArrival":
limit = (
carapi.lastChargeLimitApplied
if carapi.lastChargeLimitApplied != 0
else -1
)
carapi.applyChargeLimit(limit=limit, checkArrival=True)
elif task["cmd"] == "checkCharge":
carapi.updateChargeAtHome()
elif task["cmd"] == "checkDeparture":
carapi.applyChargeLimit(
limit=carapi.lastChargeLimitApplied, checkDeparture=True
)
elif task["cmd"] == "checkGreenEnergy":
check_green_energy()
elif task["cmd"] == "getLifetimekWh":
master.getSlaveLifetimekWh()
elif task["cmd"] == "getVehicleVIN":
master.getVehicleVIN(task["slaveTWC"], task["vinPart"])
elif task["cmd"] == "snapHistoryData":
master.snapHistoryData()
elif task["cmd"] == "updateStatus":
update_statuses()
elif task["cmd"] == "webhook":
if config["config"].get("webhookMethod", "POST") == "GET":
requests.get(task["url"])
else:
body = master.getStatus()
requests.post(task["url"], json=body)
elif task["cmd"] == "saveSettings":
master.saveSettings()
except:
logger.info(
"%s: "
+ traceback.format_exc()
+ ", occurred when processing background task",
"BackgroundError",
extra={"colored": "red"},
)
pass
# Delete task['cmd'] from backgroundTasksCmds such that
# queue_background_task() can queue another task['cmd'] in the future.
master.deleteBackgroundTask(task)
# task_done() must be called to let the queue know the task is finished.
# backgroundTasksQueue.join() can then be used to block until all tasks
# in the queue are done.
master.doneBackgroundTask()
def check_green_energy():
global config, hass, master
# Check solar panel generation using an API exposed by
# the HomeAssistant API.
#
# You may need to customize the sensor entity_id values
# to match those used in your environment. This is configured
# in the config section at the top of this file.
#
greenEnergyAmpsOffset = config["config"]["greenEnergyAmpsOffset"]
if greenEnergyAmpsOffset >= 0:
master.setConsumption(
"Manual", master.convertAmpsToWatts(greenEnergyAmpsOffset)
)
else:
master.setGeneration(
"Manual", -1 * master.convertAmpsToWatts(greenEnergyAmpsOffset)
)
# Poll all loaded EMS modules for consumption and generation values
for module in master.getModulesByType("EMS"):
master.setConsumption(module["name"], module["ref"].getConsumption())
master.setGeneration(module["name"], module["ref"].getGeneration())
# Set max amps iff charge_amps isn't specified on the policy.
if master.getModuleByName("Policy").policyIsGreen():
master.setMaxAmpsToDivideAmongSlaves(master.getMaxAmpsToDivideGreenEnergy())
def update_statuses():
# Print a status update if we are on track green energy showing the
# generation and consumption figures
maxamps = master.getMaxAmpsToDivideAmongSlaves()
maxampsDisplay = f("{maxamps:.2f}A")
if master.getModuleByName("Policy").policyIsGreen():
genwatts = master.getGeneration()
conwatts = master.getConsumption()
chgwatts = master.getChargerLoad()
genwattsDisplay = f("{genwatts:.0f}W")
conwattsDisplay = f("{conwatts:.0f}W")
chgwattsDisplay = f("{chgwatts:.0f}W")
if config["config"]["subtractChargerLoad"]:
othwatts = conwatts - chgwatts
othwattsDisplay = f("{othwatts:.0f}W")
logger.info(
"Green energy generates %s, Consumption %s (Other Load %s, Charger Load %s)",
genwattsDisplay,
conwattsDisplay,
othwattsDisplay,
chgwattsDisplay,
extra={
"logtype": "green_energy",
"genWatts": genwatts,
"conWatts": conwatts,
"chgWatts": chgwatts,
"colored": "magenta"
},
)
else:
logger.info(
"Green energy generates %s, Consumption %s, Charger Load %s",
genwattsDisplay,
conwattsDisplay,
chgwattsDisplay,
extra={
"logtype": "green_energy",
"genWatts": genwatts,
"conWatts": conwatts,
"chgWatts": chgwatts,
"colored": "magenta"
},
)
nominalOffer = master.convertWattsToAmps(
genwatts
- (conwatts - (chgwatts if config["config"]["subtractChargerLoad"] else 0))
)
if abs(maxamps - nominalOffer) > 0.005:
nominalOfferDisplay = f("{nominalOffer:.2f}A")
logger.debug(
f(
"Offering {maxampsDisplay} instead of {nominalOfferDisplay} to compensate for inexact current draw"
)
)
conwatts = genwatts - master.convertAmpsToWatts(maxamps)
generation = f("{master.convertWattsToAmps(genwatts):.2f}A")
consumption = f("{master.convertWattsToAmps(conwatts):.2f}A")
logger.info(
"Limiting charging to %s - %s = %s.",
generation,
consumption,
maxampsDisplay,
extra={"colored": "magenta"},
)
else:
# For all other modes, simply show the Amps to charge at
logger.info(
"Limiting charging to %s.", maxampsDisplay, extra={"colored": "magenta"}
)
# Print minimum charge for all charging policies
minchg = f("{config['config']['minAmpsPerTWC']}A")
logger.info(
"Charge when above %s (minAmpsPerTWC).", minchg, extra={"colored": "magenta"}
)
# Update Sensors with min/max amp values
for module in master.getModulesByType("Status"):
module["ref"].setStatus(
bytes("config", "UTF-8"),
"min_amps_per_twc",
"minAmpsPerTWC",
config["config"]["minAmpsPerTWC"],
"A",
)
module["ref"].setStatus(
bytes("all", "UTF-8"),
"max_amps_for_slaves",
"maxAmpsForSlaves",
master.getMaxAmpsToDivideAmongSlaves(),
"A",
)
#
# End functions
#
##############################
##############################
#
# Begin global vars
#
data = ""
dataLen = 0
ignoredData = bytearray()
msg = bytearray()
msgLen = 0
numInitMsgsToSend = 10
msgRxCount = 0
idxSlaveToSendNextHeartbeat = 0
timeLastkWhDelivered = time.time()
timeLastkWhSaved = time.time()
timeLastHeartbeatDebugOutput = 0
webMsgPacked = ""
webMsgMaxSize = 300
webMsgResult = 0
timeTo0Aafter06 = 0
timeToRaise2A = 0
#
# End global vars
#
##############################
##############################
#
# Begin main program
#
# Instantiate necessary classes
master = TWCMaster(fakeTWCID, config)
# Instantiate all modules in the modules_available list automatically
for module in modules_available:
modulename = []
if str(module).find(".") != -1:
modulename = str(module).split(".")
try:
moduleref = importlib.import_module("lib.TWCManager." + module)
modclassref = getattr(moduleref, modulename[1])
modinstance = modclassref(master)
# Register the new module with master class, so every other module can
# interact with it
master.registerModule(
{"name": modulename[1], "ref": modinstance, "type": modulename[0]}
)
except ImportError as e:
logger.error(
"%s: " + str(e) + ", when importing %s, not using %s",
"ImportError",
module,
module,
extra={"colored": "red"},
)
except ModuleNotFoundError as e:
logger.info(
"%s: " + str(e) + ", when importing %s, not using %s",
"ModuleNotFoundError",
module,
module,
extra={"colored": "red"},
)
except:
raise
# Load settings from file
master.loadSettings()
# Create a background thread to handle tasks that take too long on the main
# thread. For a primer on threads in Python, see:
# http://www.laurentluce.com/posts/python-threads-synchronization-locks-rlocks-semaphores-conditions-events-and-queues/
backgroundTasksThread = threading.Thread(target=background_tasks_thread, args=(master,))
backgroundTasksThread.daemon = True
backgroundTasksThread.start()
logger.info(
"TWC Manager starting as fake %s with id %02X%02X and sign %02X"
% (
("Master" if config["config"]["fakeMaster"] else "Slave"),
ord(fakeTWCID[0:1]),
ord(fakeTWCID[1:2]),
ord(master.getSlaveSign()),
)
)
while True:
try:
# In this area, we always send a linkready message when we first start.
# Whenever there is no data available from other TWCs to respond to,
# we'll loop back to this point to send another linkready or heartbeat
# message. By only sending our periodic messages when no incoming
# message data is available, we reduce the chance that we will start
# transmitting a message in the middle of an incoming message, which
# would corrupt both messages.
# Add a 25ms sleep to prevent pegging pi's CPU at 100%. Lower CPU means
# less power used and less waste heat.
time.sleep(0.025)
now = time.time()
if config["config"]["fakeMaster"] == 1:
# A real master sends 5 copies of linkready1 and linkready2 whenever
# it starts up, which we do here.
# It doesn't seem to matter if we send these once per second or once
# per 100ms so I do once per 100ms to get them over with.
if numInitMsgsToSend > 5:
master.send_master_linkready1()
time.sleep(0.1) # give slave time to respond
numInitMsgsToSend -= 1
elif numInitMsgsToSend > 0:
master.send_master_linkready2()
time.sleep(0.1) # give slave time to respond
numInitMsgsToSend = numInitMsgsToSend - 1
else:
# After finishing the 5 startup linkready1 and linkready2
# messages, master will send a heartbeat message to every slave
# it's received a linkready message from. Do that here.
# A real master would keep sending linkready messages periodically
# as long as no slave was connected, but since real slaves send
# linkready once every 10 seconds till they're connected to a
# master, we'll just wait for that.
if time.time() - master.getTimeLastTx() >= 1.0:
# It's been about a second since our last heartbeat.
if master.countSlaveTWC() > 0:
slaveTWC = master.getSlaveTWC(idxSlaveToSendNextHeartbeat)
if time.time() - slaveTWC.timeLastRx > 26:
# A real master stops sending heartbeats to a slave
# that hasn't responded for ~26 seconds. It may
# still send the slave a heartbeat every once in
# awhile but we're just going to scratch the slave
# from our little black book and add them again if
# they ever send us a linkready.
logger.info(
"WARNING: We haven't heard from slave "
"%02X%02X for over 26 seconds. "
"Stop sending them heartbeat messages."
% (slaveTWC.TWCID[0], slaveTWC.TWCID[1])
)
master.deleteSlaveTWC(slaveTWC.TWCID)
else:
slaveTWC.send_master_heartbeat()
idxSlaveToSendNextHeartbeat = idxSlaveToSendNextHeartbeat + 1
if idxSlaveToSendNextHeartbeat >= master.countSlaveTWC():
idxSlaveToSendNextHeartbeat = 0
time.sleep(0.1) # give slave time to respond
else:
# As long as a slave is running, it sends link ready messages every
# 10 seconds. They trigger any master on the network to handshake
# with the slave and the master then sends a status update from the
# slave every 1-3 seconds. Master's status updates trigger the slave
# to send back its own status update.
# As long as master has sent a status update within the last 10
# seconds, slaves don't send link ready.
# I've also verified that masters don't care if we stop sending link
# ready as long as we send status updates in response to master's
# status updates.
if (
config["config"]["fakeMaster"] != 2
and time.time() - master.getTimeLastTx() >= 10.0
):
logger.info(
"Advertise fake slave %02X%02X with sign %02X is "
"ready to link once per 10 seconds as long as master "
"hasn't sent a heartbeat in the last 10 seconds."
% (
ord(fakeTWCID[0:1]),
ord(fakeTWCID[1:2]),
ord(master.getSlaveSign()),
)
)
master.send_slave_linkready()
# See if there's any message from the web interface.
if master.getModuleByName("WebIPCControl"):
master.getModuleByName("WebIPCControl").processIPC()
# If it has been more than 2 minutes since the last kWh value,
# queue the command to request it from slaves
if config["config"]["fakeMaster"] == 1 and (
(time.time() - master.lastkWhMessage) > (60 * 2)
):
master.lastkWhMessage = time.time()
master.queue_background_task({"cmd": "getLifetimekWh"})
# If it has been more than 1 minute since the last VIN query with no
# response, and if we haven't queried more than 5 times already for this
# slave TWC, repeat the query
master.retryVINQuery()
########################################################################
# See if there's an incoming message on the input interface.
timeMsgRxStart = time.time()
while True:
now = time.time()
dataLen = master.getInterfaceModule().getBufferLen()
if dataLen == 0:
if msgLen == 0:
# No message data waiting and we haven't received the
# start of a new message yet. Break out of inner while
# to continue at top of outer while loop where we may
# decide to send a periodic message.
break
else:
# No message data waiting but we've received a partial
# message that we should wait to finish receiving.
if now - timeMsgRxStart >= 2.0:
logger.log(
logging.INFO9,
"Msg timeout ("
+ hex_str(ignoredData)
+ ") "
+ hex_str(msg[0:msgLen]),
)
msgLen = 0
ignoredData = bytearray()
break
time.sleep(0.025)
continue
else:
dataLen = 1
data = master.getInterfaceModule().read(dataLen)
if dataLen != 1:
# This should never happen
logger.info("WARNING: No data available.")
break
timeMsgRxStart = now
timeLastRx = now
if msgLen == 0 and data[0] != 0xC0:
# We expect to find these non-c0 bytes between messages, so
# we don't print any warning at standard debug levels.
logger.log(
logging.DEBUG2, "Ignoring byte %02X between messages." % (data[0])
)
ignoredData += data
continue
elif msgLen > 0 and msgLen < 15 and data[0] == 0xC0:
# If you see this when the program is first started, it
# means we started listening in the middle of the TWC
# sending a message so we didn't see the whole message and
# must discard it. That's unavoidable.
# If you see this any other time, it means there was some
# corruption in what we received. It's normal for that to
# happen every once in awhile but there may be a problem
# such as incorrect termination or bias resistors on the
# rs485 wiring if you see it frequently.
logger.debug(
"Found end of message before full-length message received. "
"Discard and wait for new message."
)
msg = data
msgLen = 1
continue
if msgLen == 0:
msg = bytearray()
msg += data
msgLen += 1
# Messages are usually 17 bytes or longer and end with \xc0\xfe.
# However, when the network lacks termination and bias
# resistors, the last byte (\xfe) may be corrupted or even
# missing, and you may receive additional garbage bytes between
# messages.
#
# TWCs seem to account for corruption at the end and between
# messages by simply ignoring anything after the final \xc0 in a
# message, so we use the same tactic. If c0 happens to be within
# the corrupt noise between messages, we ignore it by starting a
# new message whenever we see a c0 before 15 or more bytes are
# received.
#
# Uncorrupted messages can be over 17 bytes long when special
# values are "escaped" as two bytes. See notes in sendMsg.
#
# To prevent most noise between messages, add a 120ohm
# "termination" resistor in parallel to the D+ and D- lines.
# Also add a 680ohm "bias" resistor between the D+ line and +5V
# and a second 680ohm "bias" resistor between the D- line and
# ground. See here for more information:
# https://www.ni.com/support/serial/resinfo.htm
# http://www.ti.com/lit/an/slyt514/slyt514.pdf
# This explains what happens without "termination" resistors:
# https://e2e.ti.com/blogs_/b/analogwire/archive/2016/07/28/rs-485-basics-when-termination-is-necessary-and-how-to-do-it-properly
if msgLen >= 16 and data[0] == 0xC0:
break
if msgLen >= 16:
msg = unescape_msg(msg, msgLen)
# Set msgLen = 0 at start so we don't have to do it on errors below.
# len($msg) now contains the unescaped message length.
msgLen = 0
msgRxCount += 1
# When the sendTWCMsg web command is used to send a message to the
# TWC, it sets lastTWCResponseMsg = b''. When we see that here,
# set lastTWCResponseMsg to any unusual message received in response
# to the sent message. Never set lastTWCResponseMsg to a commonly
# repeated message like master or slave linkready, heartbeat, or
# voltage/kWh report.
if (
master.lastTWCResponseMsg == b""
and msg[0:2] != b"\xFB\xE0"
and msg[0:2] != b"\xFD\xE0"
and msg[0:2] != b"\xFC\xE1"
and msg[0:2] != b"\xFB\xE2"
and msg[0:2] != b"\xFD\xE2"
and msg[0:2] != b"\xFB\xEB"
and msg[0:2] != b"\xFD\xEB"
and msg[0:2] != b"\xFD\xE0"
):
master.lastTWCResponseMsg = msg
logger.log(
logging.INFO9,
"Rx@" + ": (" + hex_str(ignoredData) + ") " + hex_str(msg) + "",
)
ignoredData = bytearray()
# After unescaping special values and removing the leading and
# trailing C0 bytes, the messages we know about are always 14 bytes
# long in original TWCs, or 16 bytes in newer TWCs (protocolVersion
# == 2).
if len(msg) != 14 and len(msg) != 16 and len(msg) != 20:
logger.info(
"ERROR: Ignoring message of unexpected length %d: %s"
% (len(msg), hex_str(msg))
)
continue
checksumExpected = msg[len(msg) - 1]
checksum = 0
for i in range(1, len(msg) - 1):
checksum += msg[i]
if (checksum & 0xFF) != checksumExpected:
logger.info(
"ERROR: Checksum %X does not match %02X. Ignoring message: %s"
% (checksum, checksumExpected, hex_str(msg))
)
continue
if config["config"]["fakeMaster"] == 1:
############################
# Pretend to be a master TWC
foundMsgMatch = False
# We end each regex message search below with \Z instead of $
# because $ will match a newline at the end of the string or the
# end of the string (even without the re.MULTILINE option), and
# sometimes our strings do end with a newline character that is
# actually the CRC byte with a value of 0A or 0D.
msgMatch = re.search(b"^\xfd\xb1(..)\x00\x00.+\Z", msg, re.DOTALL)
if msgMatch and foundMsgMatch == False:
# Handle acknowledgement of Start command
foundMsgMatch = True
senderID = msgMatch.group(1)
msgMatch = re.search(b"^\xfd\xb2(..)\x00\x00.+\Z", msg, re.DOTALL)
if msgMatch and foundMsgMatch == False:
# Handle acknowledgement of Stop command
foundMsgMatch = True
senderID = msgMatch.group(1)
msgMatch = re.search(
b"^\xfd\xe2(..)(.)(..)\x00\x00\x00\x00\x00\x00.+\Z", msg, re.DOTALL
)
if msgMatch and foundMsgMatch == False:
# Handle linkready message from slave.
#
# We expect to see one of these before we start sending our
# own heartbeat message to slave.
# Once we start sending our heartbeat to slave once per
# second, it should no longer send these linkready messages.
# If slave doesn't hear master's heartbeat for around 10
# seconds, it sends linkready once per 10 seconds and starts
# flashing its red LED 4 times with the top green light on.
# Red LED stops flashing if we start sending heartbeat
# again.
foundMsgMatch = True
senderID = msgMatch.group(1)
sign = msgMatch.group(2)
maxAmps = ((msgMatch.group(3)[0] << 8) + msgMatch.group(3)[1]) / 100
logger.info(
"%.2f amp slave TWC %02X%02X is ready to link. Sign: %s"
% (maxAmps, senderID[0], senderID[1], hex_str(sign))
)
if maxAmps >= 80:
# U.S. chargers need a spike to 21A to cancel a 6A
# charging limit imposed in an Oct 2017 Tesla car
# firmware update. See notes where
# spikeAmpsToCancel6ALimit is used.
master.setSpikeAmps(21)
else:
# EU chargers need a spike to only 16A. This value
# comes from a forum post and has not been directly
# tested.
master.setSpikeAmps(16)
if senderID == fakeTWCID:
logger.info(
"Slave TWC %02X%02X reports same TWCID as master. "
"Slave should resolve by changing its TWCID."
% (senderID[0], senderID[1])
)
# I tested sending a linkready to a real master with the
# same TWCID as master and instead of master sending back
# its heartbeat message, it sent 5 copies of its
# linkready1 and linkready2 messages. Those messages
# will prompt a real slave to pick a new random value
# for its TWCID.
#
# We mimic that behavior by setting numInitMsgsToSend =
# 10 to make the idle code at the top of the for()
# loop send 5 copies of linkready1 and linkready2.
numInitMsgsToSend = 10
continue
# We should always get this linkready message at least once
# and generally no more than once, so this is a good
# opportunity to add the slave to our known pool of slave
# devices.
slaveTWC = master.newSlave(senderID, maxAmps)
if (
slaveTWC.protocolVersion == 1
and slaveTWC.minAmpsTWCSupports == 6
):
if len(msg) == 14:
slaveTWC.protocolVersion = 1
slaveTWC.minAmpsTWCSupports = 5
elif len(msg) == 16:
slaveTWC.protocolVersion = 2
slaveTWC.minAmpsTWCSupports = 6
logger.info(
"Set slave TWC %02X%02X protocolVersion to %d, minAmpsTWCSupports to %d."
% (
senderID[0],
senderID[1],
slaveTWC.protocolVersion,
slaveTWC.minAmpsTWCSupports,
)
)
# We expect maxAmps to be 80 on U.S. chargers and 32 on EU
# chargers. Either way, don't allow
# slaveTWC.wiringMaxAmps to be greater than maxAmps.
if slaveTWC.wiringMaxAmps > maxAmps:
logger.info(
"\n\n!!! DANGER DANGER !!!\nYou have set wiringMaxAmpsPerTWC to "
+ str(config["config"]["wiringMaxAmpsPerTWC"])
+ " which is greater than the max "
+ str(maxAmps)
+ " amps your charger says it can handle. "
"Please review instructions in the source code and consult an "
"electrician if you don't know what to do."
)
slaveTWC.wiringMaxAmps = maxAmps / 4
# Make sure we print one SHB message after a slave
# linkready message is received by clearing
# lastHeartbeatDebugOutput. This helps with debugging
# cases where I can't tell if we responded with a
# heartbeat or not.
slaveTWC.lastHeartbeatDebugOutput = ""
slaveTWC.timeLastRx = time.time()
slaveTWC.send_master_heartbeat()
else:
msgMatch = re.search(
b"\A\xfd\xe0(..)(..)(.......+?).\Z", msg, re.DOTALL
)
if msgMatch and foundMsgMatch == False:
# Handle heartbeat message from slave.
#
# These messages come in as a direct response to each
# heartbeat message from master. Slave does not send its
# heartbeat until it gets one from master first.
# A real master sends heartbeat to a slave around once per
# second, so we do the same near the top of this for()
# loop. Thus, we should receive a heartbeat reply from the
# slave around once per second as well.
foundMsgMatch = True
senderID = msgMatch.group(1)
receiverID = msgMatch.group(2)
heartbeatData = msgMatch.group(3)
try:
slaveTWC = master.getSlaveByID(senderID)
except KeyError:
# Normally, a slave only sends us a heartbeat message if
# we send them ours first, so it's not expected we would
# hear heartbeat from a slave that's not in our list.
logger.info(
"ERROR: Received heartbeat message from "
"slave %02X%02X that we've not met before."
% (senderID[0], senderID[1])
)
continue
if fakeTWCID == receiverID:
slaveTWC.receive_slave_heartbeat(heartbeatData)
else:
# I've tried different fakeTWCID values to verify a
# slave will send our fakeTWCID back to us as
# receiverID. However, I once saw it send receiverID =
# 0000.
# I'm not sure why it sent 0000 and it only happened
# once so far, so it could have been corruption in the
# data or an unusual case.
logger.info(
"WARNING: Slave TWC %02X%02X status data: "
"%s sent to unknown TWC %02X%02X."
% (
senderID[0],
senderID[1],
hex_str(heartbeatData),
receiverID[0],
receiverID[1],
)
)
else:
msgMatch = re.search(
b"\A\xfd\xeb(..)(....)(..)(..)(..)(.+?).\Z", msg, re.DOTALL
)
if msgMatch and foundMsgMatch == False:
# Handle kWh total and voltage message from slave.
#
# This message can only be generated by TWCs running newer
# firmware. I believe it's only sent as a response to a
# message from Master in this format:
# FB EB <Master TWCID> <Slave TWCID> 00 00 00 00 00 00 00 00 00
# According to FuzzyLogic, this message has the following
# format on an EU (3-phase) TWC:
# FD EB <Slave TWCID> 00000038 00E6 00F1 00E8 00
# 00000038 (56) is the total kWh delivered to cars
# by this TWC since its construction.
# 00E6 (230) is voltage on phase A
# 00F1 (241) is voltage on phase B
# 00E8 (232) is voltage on phase C
#
# I'm guessing in world regions with two-phase power that
# this message would be four bytes shorter, but the pattern
# above will match a message of any length that starts with
# FD EB.
foundMsgMatch = True
senderID = msgMatch.group(1)