-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathcar
More file actions
executable file
·523 lines (371 loc) · 20.8 KB
/
car
File metadata and controls
executable file
·523 lines (371 loc) · 20.8 KB
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
#!/usr/bin/env python3
###############################################################################
###############################################################################
# Copyright (c) 2025, Andy Schroder
# See the file README.md for licensing information.
###############################################################################
###############################################################################
################################################################
# import modules
################################################################
# dc must be the first module initialized and then immediately set the mode
import dc
dc.mode='car'
# dc.common must be the second imported module because it reads the config
from dc.common import ConfigFile, SWCAN_Relay, SWCANMessagesClass, ReceiveInvoices, TWCANMessagesClass, lnd, GUI, logger, LogData, ThreadManagerClass
from time import sleep,time
from datetime import datetime,timedelta
from helpers2 import FormatTimeDeltaToPaddedString,RoundAndPadToString,SetPrintWarningMessages, FullDateTimeString
from textwrap import indent,TextWrapper
import sys
from bolt11.core import decode
from math import ceil
################################################################
# define configuration related constants
################################################################
kW=1.0*10**3 # W
MaxRate_kW=1_000 # sat/(kW*hour)
MaxRate=MaxRate_kW/kW
MaxRequiredPaymentAmount=41 #sat
MaxFeeFraction=0.03
MaxFeeSat=2
################################################################
################################################################
# initialize variables
################################################################
Proximity=False
SWCANActive=False
AcceptedRate=False
Power=0
ChargeStartTime=-1
CurrentRate=0
RequiredPaymentAmountAccepted=0
EnergyDelivered=0
EnergyPaidFor=0
NumberOfPaymentsReceived=0
################################################################
################################################################
# define functions and classes
################################################################
class DocumentWrapper(TextWrapper):
# inspired from https://stackoverflow.com/questions/1166317/python-textwrap-library-how-to-preserve-line-breaks/45287550#45287550
# keep original newlines and only wrap lines that are longer than the limit
def wrap(self, text):
split_text = text.split('\n')
lines = [line for para in split_text for line in TextWrapper.wrap(self, para)]
return lines
################################################################
################################################################
# start up threads
################################################################
ReceiveInvoicesThread=ReceiveInvoices()
SWCANMessages=SWCANMessagesClass()
TWCANMessages=TWCANMessagesClass()
ThreadManager=ThreadManagerClass()
ThreadManager.AddThread(GUI)
ThreadManager.AddThread(SWCANMessages)
ThreadManager.AddThread(TWCANMessages)
ThreadManager.AddThread(ReceiveInvoicesThread)
################################################################
# hack: create an empty Meter class to use as a placeholder until the real Meter function is ported from GRID to EV so that LogData can work
class Meter: pass
GUI.StatusAdd(BigText='Insert Charge Cable Into Car', SmallText='Waiting For Charge Cable To Be Inserted')
logger.info("startup complete, ready to insert charge cable")
while True:
try:
#pass values to the GUI
GUI.Volts=TWCANMessages.Volts
GUI.Amps=TWCANMessages.Amps
GUI.Power=Power
GUI.EnergyDelivered=EnergyDelivered
GUI.EnergyCost=EnergyDelivered*CurrentRate
GUI.CreditRemaining=(EnergyPaidFor-EnergyDelivered)*CurrentRate
GUI.RecentRate=CurrentRate
GUI.RequiredPaymentAmount=RequiredPaymentAmountAccepted
GUI.ChargeStartTime=ChargeStartTime
GUI.MaxAmps=TWCANMessages.MaxAmps
GUI.SettledPayments=EnergyPaidFor*CurrentRate
######################
# TODO: move proximity detection to a separate thread
######################
if TWCANMessages.CHG_PROXIMITY_LATCHED:
if not Proximity:
Proximity=True
logger.info("plug inserted")
GUI.StatusAdd(BigText='Charge Cable Inserted')
CurrentTime=time()
TotalWhoursCharged_start=-1
EnergyDelivered=0
EnergyPaidFor=0
NumberOfPaymentsReceived=0
ChargeStartTime=datetime.now()
AcceptedRate=False
CurrentRate=0
RequiredPaymentAmountAccepted=0
DataLogger=LogData(Meter,GUI)
else:
# still plugged in
pass
else:
if Proximity:
Proximity=False
DataLogger.close()
# disconnect SWCAN if it is on
SWCAN_Relay.off()
logger.debug("relay off")
logger.debug("plug removed\n\n\n")
GUI.StatusAdd(BigText='Charge Cable Removed', MinDisplayTime=2)
GUI.StatusAdd(BigText='Insert Charge Cable Into Car', SmallText='Waiting For Charge Cable To Be Inserted')
# clear message values received from the bus. otherwise an old offer will be accepted when re-plugging in the bus before the relay is
# even energized and/or before wall sends an offer and then wall will never get an acceptance message.
# TODO: decide if is there any kind of delay needed here in case a new message value is received while the relay is mechanically de-energizing?
SWCANMessages.Rate=None
SWCANMessages.RequiredPaymentAmount=None
TWCANMessages.Volts=None
TWCANMessages.Amps=None
Power=0
TWCANMessages.MaxAmps=0
# stop counting charge time, but don't reset the counter yet
ChargeStartTime =-1
else:
# first startup and never plugged in or already unplugged
pass
if TWCANMessages.TESLA_SWCAN_ESTABLISHED:
if not SWCANActive:
GUI.StatusAdd(SmallText='SWCAN Detected')
SWCANActive=True
# it seems like staying connected when idle causes charge faults sometimes.
# disconnect on anything else for now (may want to revisit all states and see if want to stay connected on sleep for example)
# does not seem to send another signal before going into sleep mode. need to figure out something else to do to detect, or also use voltage measured
# from pilot/proximity pin to have more confidence on what is going on, like how the wall unit operates.
# causes problems and then car errors out even though SWCAN is actually active, canbus doesn't think so, so ....
# also need to consider having a 15 second delay between pluggin/unplugging like the wall unit, so that they are both measuring energy delivery from the same start time
if TWCANMessages.AC_CHARGE_ENABLED: # have SWCAN, but only actually listen when the car seems like it is trying to actively charge
if not SWCAN_Relay.is_lit: # car is actively trying to charge and SWCAN is not connected, connect SWCAN
SWCAN_Relay.on()
logger.debug("AC_CHARGE_ENABLED, relay energized")
logger.info("SWCANActive")
if GUI.StatusQueue[0][0]=='Charging Idle': # need a more reliable way to do this??? better to use `GUI.BigStatusTextv.get()` or is there an even more explicit way??
logger.debug('Charging Resume From Idle')
GUI.StatusAdd(BigText='Charging')
else:
# car is actively trying to charge and SWCAN already connected
pass
else: # car seems like it went into idle mode, disconnect SWCAN if it is connected
if SWCAN_Relay.is_lit: # SWCAN is connected, disconnect it
SWCAN_Relay.off()
logger.debug("relay off")
logger.debug('Charging Idle')
GUI.StatusAdd(BigText='Charging Idle', SmallText='Waiting For Car To Resume Charging', MinDisplayTime=2)
else:
# car idle and SWCAN already disconnected
pass
else:
SWCANActive=False
######################
if Proximity:
#################################################################
# do this stuff before testing for AcceptedRate because want to
# still monitor power and energy if not paying via distributed charge.
#################################################################
if TWCANMessages.TotalWhoursCharged !=-1:
if TotalWhoursCharged_start==-1: # just plugged in
TotalWhoursCharged_start=TWCANMessages.TotalWhoursCharged
# not yet used. need to add to the GUI or some other kind of report. can help understand how much energy is wasted warming the battery up as well as charger
# efficinecy since the Tesla GUI is very misleading on how much energy you are actually using
EnergyAddedToBattery=TWCANMessages.TotalWhoursCharged-TotalWhoursCharged_start
if (TWCANMessages.Volts is not None) and (TWCANMessages.Amps is not None): # can't start doing anything until an initial voltage and current reading is obtained on the can bus because need that to decide when to pay.
PreviousTime=CurrentTime
CurrentTime=time()
deltaT=(CurrentTime-PreviousTime)/3600 # hours
Power=TWCANMessages.Volts*TWCANMessages.Amps # W
EnergyDelivered+=deltaT*Power # W*hours
#################################################
# hack: define values for the Meter class to use
# as a placeholder until the real Meter class
# is ported from GRID to EV so that LogData can work
#################################################
Meter.Power=Power
Meter.Volts=TWCANMessages.Volts
Meter.Amps=TWCANMessages.Amps
Meter.EnergyDelivered=EnergyDelivered
Meter.EnergyCost=EnergyDelivered*CurrentRate
Meter.RecentRate=CurrentRate
Meter.SalePeriods = 1
Meter.SellOfferTerms = {
'OfferStartTime' : time(),
'OfferStopTime' : time(),
}
Meter.BuyOfferTerms = {
'RateInterpolator' : None,
}
#################################################
#################################################################
if AcceptedRate:
if len(ReceiveInvoicesThread.InvoiceQueue)>0: # invoices are waiting to be paid
oldestInvoice=ReceiveInvoicesThread.InvoiceQueue.popleft()
logger.debug("decoding "+oldestInvoice)
AmountRequested=int(decode(oldestInvoice).amount/1000)
logger.info("seller wants to be paid "+str(AmountRequested)+" satoshis")
GUI.StatusAdd(SmallText=RoundAndPadToString(AmountRequested,0)+' sat Payment Requested', MinDisplayTime=1)
# NOTE: measurement error seems to be somewhat linear between car and charger. need to further investigate....
# also, this formula assumes the current is constant throughout the session. if the current is initially low and then goes up, it might not work because the new current is used for all former error.
AllowedError=(0.025-0.20)/(48-5)*(TWCANMessages.Amps-5)+0.2
if ( # TODO as noted elsewhere, need rework this to be in sat not W*hour
# NOTE: not asking for payment before energy is delivered (allowed to pay after 30% has been
# delivered (70% ahead of time)---actually, poor internet connections can be very slow,
# so make this 140% ahead instead. also tolerate error, including a linear error and a
# fixed error that is a little generous right now but occurs during initial plug in
# because the car and wall unit start measuring at slightly different times.
((EnergyPaidFor-EnergyDelivered)<(WhoursPerPaymentAccepted*0.70*2+(EnergyDelivered*AllowedError+75)))
and
(
(AmountRequested<=RequiredPaymentAmountAccepted) #not asking for too much payment
or
(
(AmountRequested<=2*RequiredPaymentAmountAccepted)
and
(EnergyPaidFor==0) #first payment allows 2x normal payment amount.
)
)
): #if all good, then it's time to send another invoice
try:
LNDBalance=lnd.channel_balance().local_balance.sat
logger.info('LND (off chain) account balance : '+RoundAndPadToString(LNDBalance,0)+' sat')
except:
logger.exception('tried getting LND (off chain) account balance but there was probably a network connection issue.')
ReceiveInvoicesThread.InvoiceQueue.appendleft(oldestInvoice) #put the invoice back in the queue
sleep(2)
else:
if LNDBalance<AmountRequested*(1+MaxFeeFraction):
logger.error('LND (off chain) account balance is too low')
sleep(20)
else:
try:
MaxAllowableFee=max(ceil(AmountRequested*MaxFeeFraction),MaxFeeSat)
logger.info("sending payment for "+RoundAndPadToString(AmountRequested,0)+" sat with a max allowable fee of "+RoundAndPadToString(MaxAllowableFee,0)+' sat ('+RoundAndPadToString(100*(MaxAllowableFee/AmountRequested),2)+'%)')
# TODO: should check to make sure the "expiry" has not passed on the invoice yet before paying????
# allow changing final_cltv_delta of the sent payment?
# also, can to be a very long time until timeout on network failure so this exception isn't caught
# very quickly and the GUI never updates while it is waiting. might want to move this to another thread???
# send the payment and display updates as they are streamed during routing and settlement
for PaymentResponse in lnd.send_payment_v2(payment_request=oldestInvoice, fee_limit_sat=MaxAllowableFee, timeout_seconds=25, allow_self_payment=True):
logger.debug('====================================================================================\n' + indent(DocumentWrapper(subsequent_indent=' ',width=80).fill(str(PaymentResponse)),' '*53))
if PaymentResponse.status != 2:
raise Exception('payment did not succeed, status is '+str(PaymentResponse.status))
except:
logger.exception("tried sending payment but there was an issue")
ReceiveInvoicesThread.InvoiceQueue.appendleft(oldestInvoice) #put the invoice back in the queue
sleep(2)
else:
logger.info('sent payment: total fees = '+RoundAndPadToString(PaymentResponse.fee_sat,0)+' [sat] ('+RoundAndPadToString(100*(PaymentResponse.fee_sat/AmountRequested),2)+'%)')
logger.info('total outstanding invoices is now '+str(len(ReceiveInvoicesThread.InvoiceQueue)))
# TODO as noted elsewhere, need rework this to be in sat not W*hour
EnergyPaidFor+=AmountRequested/CurrentRate
NumberOfPaymentsReceived+=1
#################################################
# hack: define values for the Meter class to use
# as a placeholder until the real Meter class
# is ported from GRID to EV so that LogData can work
#################################################
Meter.SettledPayments = EnergyPaidFor*CurrentRate
Meter.NumberOfPaymentsReceived = NumberOfPaymentsReceived
#################################################
DataLogger.LogTabularDataAndMessages()
logger.info('Car Battery State Of Charge: '+ RoundAndPadToString(TWCANMessages.StateOfCharge,2)+'%')
# show SmallText for 5-True*3=2 seconds if there are more invoices waiting to be paid (i.e. it is the second payment or we are behind because of a slow internet connection)
# or 5-False*3=5 seconds if there are no invoices waiting to be paid
# this way the (instantly updated) total payments table shown in the screen is less out of sync with the status messages
# because we will be showing the status messages quicker if there is a backlog and we are catching up on payments
# also assumes payments don't get made in less than 3.5 seconds (the payment requested message is shown for at least
# 1 second above and we are blanked for 0 to 0.5 below), otherwise we will always be behind and out of sync with the total payments table
# TODO: need to check and see if the LND GRPC gives an official time instead of this one.
GUI.StatusAdd(SmallText=RoundAndPadToString(AmountRequested,0)+' sat Payment Sent - ' + FullDateTimeString(datetime.now()), MinDisplayTime=(5-3*(len(ReceiveInvoicesThread.InvoiceQueue)>0)))
GUI.StatusAdd(SmallText='') # blank the previous message after its DisplayTime is over
else:
#seller is asking for payment to quickly, waiting until they deliver energy that was agreed upon.
#if they aren't happy and think they delivered enough, they will shut down.
#currently, the buyer and seller will both tolerate some error.
#because they need to give time for a payment to actually be made and account for their different instrumentation.
#need to do something if AmountRequested>RequiredPaymentAmountAccepted and EnergyPaidFor>0 ????????????????? don't remember what this comment was about.....
logger.debug("not yet time to pay, waiting")
ReceiveInvoicesThread.InvoiceQueue.appendleft(oldestInvoice) #put the invoice back in the queue
sleep(2)
else:
#logger.debug("waiting for next invoice")
pass
elif (SWCANMessages.Rate is not None) and (SWCANMessages.RequiredPaymentAmount is not None): #offer received
# make a copy so don't let a new value on the bus change what is enforced locally
CurrentRate=SWCANMessages.Rate
RequiredPaymentAmountAccepted=SWCANMessages.RequiredPaymentAmount
WhoursPerPaymentAccepted=RequiredPaymentAmountAccepted/CurrentRate
GUI.StatusAdd(SmallText='Sale Terms Offer Received: ' + str(CurrentRate) + ' sat/(W*hour)', MinDisplayTime=2)
# accept the rate, until SWCAN goes down.
# TODO: probably need to upgrade to allow rate changes during a charging session, but for now, this is how it works.
if (CurrentRate<(MaxRate)) and (RequiredPaymentAmountAccepted<MaxRequiredPaymentAmount):
ReceiveInvoicesThread.InvoiceQueue.clear() #all previous invoices are no longer be valid as far as the buyer is concerned, so ignore them
AcceptedRate=True
#print('getting ready to accept an offer')
SWCANMessages.send(arbitration_id=1999, data=[True])
logger.info("accepted an offer of "+RoundAndPadToString(CurrentRate,2)+" satoshis/(W*hour) with a payment size of "+str(RequiredPaymentAmountAccepted)+" satoshis")
GUI.StatusAdd(BigText='Charging', SmallText='Accepted Sale Terms', MinDisplayTime=2)
else: #don't accept the rate, it's too high. wait and see if a lower offer is made.
SWCANMessages.send(arbitration_id=1999, data=[False])
logger.info("rate or payment amount too high, not accepting")
GUI.StatusAdd(SmallText='Rejected Sale Terms, Waiting for a Better Offer')
# don't check again until a new offer actually comes in
SWCANMessages.Rate=None
SWCANMessages.RequiredPaymentAmount=None
# TODO: provide more detail in outputs on why was not accepted
else:
#logger.info('waiting for offer')
GUI.StatusAdd(SmallText='Waiting for Sale Terms Offer')
else:
#logger.info('no voltage or current found yet')
GUI.StatusAdd(SmallText='Waiting for Car\'s Voltage and Current Reading')
else:
#logger.info('no proximity')
pass
################################################################
# shutdown logic
################################################################
if not ThreadManager.ShutdownRequested and GUI.stopped() and not GUI.is_alive():
logger.info('GUI triggered shutdown request')
# need to re-call SystemExit outside of the GUI thread
sys.exit()
if ThreadManager.AnyThreadAlive():
if ThreadManager.ShutdownRequested:
logger.error('all threads did not shut down on their own, terminating the remaining threads')
break
else:
# nothing to do, not time to shut down
sleep(.1)
else:
if ThreadManager.ShutdownRequested:
logger.debug('all threads shut down on their own after being asked')
break
else:
logger.debug('all threads shut down on their own without being asked')
break
# TODO: also add a check for one thread shut down on it's own and then force shutdown of everything since things probably
# won't work right with a dead thread?? that could then generalize things a bit because wouldn't need to specifically
# check for the GUI thread to have stopped?
except (KeyboardInterrupt, SystemExit):
ThreadManager.StopThreads()
# now loop again to see above if the threads shutdown after being asked
# does this cause finally to be executed twice then???? yes, seems it does. also seems like a more confusing way to write the logic above instead of below, so may want to re-do this.
except:
logger.exception('error in main loop')
ThreadManager.CleanShutdown=False
#should this also run ThreadManager.StopThreads() to be cleaner?????
raise
finally:
if ThreadManager.ShutdownRequested or not ThreadManager.CleanShutdown: # don't run on every loop iteration, only if shutting down
# the state should be restored to off when python is stopped, but explicitly set to off to be sure.
SWCAN_Relay.off()
if not ThreadManager.CleanShutdown: # if an uncaught exception, put some extra lines at the end
ExtraText='\n\n\n'
else:
ExtraText=''
logger.info("turned off SWCAN relay"+ExtraText)
logger.info('shutdown complete\n\n\n')