-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathwall
More file actions
executable file
·1064 lines (767 loc) · 45.6 KB
/
wall
File metadata and controls
executable file
·1064 lines (767 loc) · 45.6 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
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/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='wall'
# dc.common must be the second imported module because it reads the config
from dc.common import ConfigFile, ProximityAnalogVoltage, SWCAN_Relay, SWCANMessagesClass, SWCAN_ISOTP, GUI, logger, LogData, SMTPNotification, lnd, PilotAnalogVoltage, LogAndSmallStatusUpdate, ThreadManagerClass
from dc.NFC import NFCClass
from time import sleep,time
from datetime import datetime,timedelta
from helpers2 import FormatTimeDeltaToPaddedString,RoundAndPadToString, FullDateTimeString
import sys
from pathlib import Path
import TWCManager
from threading import Thread, Event
from bolt11.core import decode
from math import ceil
################################################################
# define configuration related constants
################################################################
ProximityVoltageInserted=1.5 # Voltage that indicates charge cable has been plugged in
ProximityVoltageInsertedTolerance=0.05*2*2.5 # need the extra *2.5 for 208V power? was getting 1.289V every once and a while and not sure why! so, it was disconnecting and screwing everything up.
StateB=False
PilotVoltageInserted=9
PilotVoltageInsertedTolerance=0.20
StateC=False
PilotVoltageInsertedAndCarReady=6
PilotVoltageInsertedAndCarReadyTolerance=0.20
CurrentRate_kW=100 # sat/(kW*hour)
CurrentRate=CurrentRate_kW/1000 # sat/(W*hour)
WhoursPerPayment=30 # W*hour/payment # TODO: decide if it is better to make it seconds/payment instead?
RequiredPaymentAmount=int(ceil(WhoursPerPayment*CurrentRate)) #sat/payment
####################
# manualpay specific config
####################
#have a look at https://github.com/lightningnetwork/lnd/commit/7e1628d89de4761f508ac07fc0c53a1dd04f6ebe for some brief discussion of cltv_expiry
cltv_expiry=144
# production
BufferBlocks=25
PrePayWhours=250_000
# test mode
#BufferBlocks=cltv_expiry
#PrePayWhours=50000
QRCodeHideDelay=4
DepositAmount=int(ceil(PrePayWhours*(CurrentRate)))
####################
################################################################
################################################################
# initialize variables
################################################################
AutoPayActive=False
ShutdownRequested=False
CleanShutdown=True
Proximity=False
OfferAccepted=False
ReInsertedMessagePrinted=False
ProximityCheckStartTime=-1
ProximityLostTime=0
TimeLastOfferSent=time()
ExpiryHeight=None
HTLCTimeRemaining=0
ManualPayState='GetNewInvoice'
GUI.QRLink=None
ManualPayActive=True # this allows to get to ManualPayState=='GetNewInvoice' on startup
################################################################
################################################################
# define functions and classes
################################################################
def TimeRounder(timedelta):
hours, seconds= divmod(timedelta.seconds, 3600)
if timedelta.days >=1:
return '~' + str(timedelta.days)+' days, '+str(round(timedelta.seconds/3600))+' hours'
elif hours>=1:
return '~' + str(hours)+' hour, '+str(round(seconds/60))+' minutes'
else:
# TODO: make this less redundant!
return '~' + str(round(seconds/60))+' minutes'
def CreditAndHTLCBlocksRemaining(KillPower):
global TheFinalInvoice, TheInitialDepositInvoice, ExpiryHeight, ManualPayState
if (Meter.CreditRemaining)<=0 or HTLCBlocksRemaining<=0:
if HTLCBlocksRemaining<=0:
if KillPower:
GUI.StatusAdd(BigText='Power OFF')
LogAndSmallStatusUpdate('Max Session Time Reached, Settling Inital Deposit of '+RoundAndPadToString(DepositAmount,0)+' sat!', 20)
# TODO: could allow the user to keep charging and just give a credit for the entire amount claimed and then kill power once that is exhausted (which it does do below)
# probably this should be an option that the seller can choose. For example, if the parking space is a dedicated rented by the buyer and the charging expense is a variable cost
# then this would make sense, but if it is a free parking space and the buyer is tying up the charger without generating any revenue for the seller, the seller would want to
# claim the "idle" time as that may be part of their purpose in the (potentially large) deposit amount they set.
else:
# if we don't need to kill power, that means the charge cable is already unplugged and the user left and just forgot to pay the final invoice.
# don't want to update BigText here because it should already say 'Cable Removed'
LogAndSmallStatusUpdate('User Never Paid Final Invoice, Settling Inital Deposit of '+RoundAndPadToString(DepositAmount,0)+' sat!')
logger.debug('canceling final invoice')
TheFinalInvoice.cancel() # TODO want to make sure this is canceled before accepting the initial deposit and should probably change to a hold invoice so don't accidentally accept it before it can be canceled.
TheFinalInvoice=None
GUI.QRLink=None
else:
# we will also be killing power below for this case because this function is only called when HTLCBlocksRemaining>0 when plugged
# in (because if we are unplugged we can't exhaust our credit, only our max session time, since power=0 while unplugged) and we always call with KillPower==True when plugged in
GUI.StatusAdd(BigText='Power OFF')
LogAndSmallStatusUpdate('Manual Pay Credit exhausted, settling initial deposit of '+RoundAndPadToString(DepositAmount,0)+' sat!', 20)
TheInitialDepositInvoice.settle()
Meter.AddSettledPayment(DepositAmount,Held=True)
TheInitialDepositInvoice=None
ExpiryHeight=None
GUI.HTLCTimeRemainingText=''
GUI.InvoiceExpirationText=''
# stop counting session time
# normally we keep the session time counting in manual pay mode whenever connected, but we can't
# get anything more from the customer because we already claimed their deposit, so
# stop counting to make it clear they need to move on.
Meter.ChargeStartTime=-1
GUI.ChargeStartTime=Meter.ChargeStartTime # need to force this early since may be sleeping a bit
if KillPower:
Meter.stopTWC()
else:
sleep(25) # want to wait because don't want the rest of the screen to update until the message finishes displaying
ManualPayState='CreditExhausted'
return False
else:
return True
class MeterClass(Thread):
def __init__(self):
super(MeterClass, self).__init__()
self._stop_thread = Event()
self.daemon=True # using daemon mode so control-C will stop the script and the threads and .join() can timeout and if the main thread crashes, then it will all crash and restart automatically (by systemd).
logger.info("starting TWCManager")
GUI.StatusAdd(BigText='Connecting to TWC')
TWCManager.MainThread.start()
LogAndSmallStatusUpdate("Looking for a wall unit to connect to", 1)
while len(TWCManager.master.slaveTWCRoundRobin)<1: # wait until connected to a wall unit.
# NOTE: this is not deterministic because seems to disconnect sometimes, especially when using crontab to lauch the scrpt on boot (which is really weird).... UPDATE: is this still an issue now that systemd is used instead?
sleep(.1)
self.myTWCID=TWCManager.master.slaveTWCRoundRobin[0].TWCID # only one wall unit now, so using number 0 blindly
LogAndSmallStatusUpdate('Should be connected to TWCID: '+str(self.myTWCID), 1)
LogAndSmallStatusUpdate("Setting Charge Amps", 1)
####################
# NOTE: not positive these are needed here since will be done below with every instance of the loop, but do it anyway because want something defined before sendStartCommand
TWCManager.master.setChargeNowAmps(ConfigFile['Seller']['MaxAmps']) # set maximum current. need to refine this statement if have multiple wall units.
TWCManager.master.setChargeNowTimeEnd(int(3600)) # set how long to hold the current for, in seconds. need to refine this statement if have multiple wall units.
####################
self.startTWC()
LogAndSmallStatusUpdate("Verifying stable voltage and current are received for 10 readings", 1)
TWCShouldBeConnectedOnce=False
for _ in range(10):
if not self.UpdateVoltsAndAmps() and TWCShouldBeConnectedOnce:
raise Exception('TWC connected and then disconnected')
elif self.UpdateVoltsAndAmps() and not TWCShouldBeConnectedOnce:
LogAndSmallStatusUpdate('First voltage and current readings received', 1)
TWCShouldBeConnectedOnce=True
elif self.UpdateVoltsAndAmps() and TWCShouldBeConnectedOnce:
LogAndSmallStatusUpdate('Additional voltage and current readings received', 1)
else:
LogAndSmallStatusUpdate('NO voltage and current readings received yet', 1)
sleep(1)
GUI.StatusAdd(BigText='Connected to TWC', MinDisplayTime=1)
logger.info("TWCManager startup complete")
#################################################
# placeholder until the a complete Meter class
# is developed that is compatable with the GRID one
#################################################
self.RecentRate=CurrentRate # this is constant for now until variable rate functionality is added. also, need to add it to Config.yaml anyway
self.SalePeriods = 1
self.SellOfferTerms = {
'OfferStartTime' : time(),
'OfferStopTime' : time(),
}
self.BuyOfferTerms = {
'RateInterpolator' : None,
}
#################################################
self.ChargeStartTime=-1 # needs to be initially defined for the GUI
self.HeldPayments=0 # this should always net to 0 after every charge session, so only set it once.
self.reset()
self.start() # auto start on initialization
def startMeasurementsNow(self):
self.CurrentTime=time()
self.ChargeStartTime=datetime.now()
self.MeasurementStarted=True
def startTWC(self):
# always need to restore power after unplugging from any manual pay session, even if power was killed because credit was exhausted
# because the next customer may be an autopay customer
self.PowerKilled=False
# does this have to be done after every time `sendStopCommand` is used or just on first startup?
# like is it not needed after `sendStopCommand` and the charge cable is unplugged?
# also, can this be run without the charge cable being unplugged to restart automatically?
LogAndSmallStatusUpdate("Sending start command to wall unit", 1)
TWCManager.master.sendStartCommand() #need to refine this statement if have multiple wall units.
# do these have to be done every time started ?????
TWCManager.master.settings['chargeStopMode']=3
TWCManager.master.saveSettings()
def reset(self):
self.MeasurementStarted=False
self.InitialInvoice=True
self.SettledPayments=0
self.CanceledPayments=0
self.EnergyDelivered=0
self.NumberOfPaymentsReceived=0
def stopTWC(self): # attempts to stop the TWC
logger.debug('killing power')
# TODO need to be smarter and make sure power is actually killed (monitor amps is one way) and retry and/or raise errors and send emails because it doesn't always seem to work.
# maybe it doesn't always work because need to also add myTWCID to the following command (and actually, should consider adding it above in some other commands as well?)?
TWCManager.master.sendStopCommand() #need to refine this statement if have multiple wall units.
# NOTE: TWC requires TeslaTap J1772 adapter to be unplugged and replugged to re-set after doing this in order to retry charging
# TODO: need to make a longer delay here(?) before restoring the screen back to the default and also maybe make sure it goes to the fully disconnected 12V state so people know to disconnect the TeslaTap J1772 adapter???
self.PowerKilled=True
def stop(self):
logger.debug('MeterClass thread stop requested')
self._stop_thread.set()
def get_EnergyPaidFor(self):
return self.SettledPayments/(self.RecentRate) # W*hours
EnergyPaidFor = property(fget=get_EnergyPaidFor)
def get_EnergyCredit(self):
return self.EnergyPaidFor-self.EnergyDelivered # W*hours
EnergyCredit = property(fget=get_EnergyCredit)
def get_CreditRemaining(self):
return self.SettledPayments+self.HeldPayments-self.EnergyCost
CreditRemaining = property(fget=get_CreditRemaining)
def get_EnergyCost(self):
return ceil(self.EnergyDelivered*self.RecentRate)
EnergyCost = property(fget=get_EnergyCost)
def AddSettledPayment(self, amount, Held=False):
self.SettledPayments += amount # sat
if Held:
self.HeldPayments -= amount
self.NumberOfPaymentsReceived += 1
# also re-measure energy change here if a payment was just received since changing the value of InitialInvoice needs to know the real Meter.EnergyCredit before repeating the loop
# AND make sure it was successful
if not self.MeasureEnergyChange():
raise Exception('could not get updated energy measurement')
def AddHeldPayment(self, amount):
self.HeldPayments += amount
def CancelHeldPayment(self, amount):
self.CanceledPayments+=DepositAmount
self.HeldPayments-=DepositAmount
def MeasureEnergyChange(self): # this is run manually for now by the parent script
if self.UpdateVoltsAndAmps(): # get fresh volts and amps values and also make sure it was successful
PreviousTime=self.CurrentTime
self.CurrentTime=time()
deltaT=(self.CurrentTime-PreviousTime)/3600 #hours, small error on first loop when SWCANActive is initially True
self.EnergyDelivered+=deltaT*self.Power #W*hours
return True
else:
return False # if we didn't get fresh volts and amps values, we need to raise a flag based on this response.
def UpdateVoltsAndAmps(self):
try:
#TODO: have to reference each time because the slave is destroyed and created if disconnected, and sometimes it disconnects??
self.Volts = TWCManager.master.slaveTWCs[self.myTWCID].voltsPhaseA # Volts AC rms
# temporarily fake if trying to test with a TWC with older(?) firmware that doesn't support voltage readings.
#self.Volts=240.0
self.Amps = TWCManager.master.slaveTWCs[self.myTWCID].reportedAmpsActual # Amps AC rms
self.Power = self.Volts * self.Amps # W, apparent power, assumes power factor=1.0
return True
except:
logger.exception('failed to get voltage or current from TWC')
return False
def run(self):
logger.info('initialized MeterClass thread')
while True:
self.UpdateVoltsAndAmps()
#not sure how to make ChargeNow perpetual, so just add an hour on every loop.
TWCManager.master.setChargeNowTimeEnd(int(3600)) #set how long to hold the current for, in seconds. need to refine this statement if have multiple wall units.
if self._stop_thread.is_set():
break
sleep(0.5)
logger.info('stopped MeterClass thread')
################################################################
################################################################
# start up threads
################################################################
SWCANMessages=SWCANMessagesClass()
NFC=NFCClass(GUI)
Meter=MeterClass()
ThreadManager=ThreadManagerClass()
ThreadManager.AddThread(GUI)
ThreadManager.AddThread(SWCANMessages)
ThreadManager.AddThread(NFC)
ThreadManager.AddThread(Meter)
ThreadManager.AddThread(TWCManager.MainThread)
################################################################
logger.info('All startup steps complete !!!!')
################################################################
# main loop
################################################################
while True:
try:
CurrentProximityAnalogVoltage=ProximityAnalogVoltage()
#logger.info("getting pilot max voltage")
PilotMaxVoltage=0
PilotFrequency=1000 # cycles/second
PilotPeriod=1/PilotFrequency # seconds
PositivePulseTimeWidth=PilotPeriod*0.05 # seconds. 5% duty cycle is the minimum to consider
PossiblePositivePulsesInPeriod=PilotPeriod/PositivePulseTimeWidth # need to sample through the whole period, because don't know when in the cycle the first reading has taken place
SampleStartTime=time()
TotalSamples=ceil(PossiblePositivePulsesInPeriod)*6#3 # sample 3x the PossiblePositivePulsesInPeriod just in case
SamplesTooSlow=0
MaxHowMuchTooSlow=0
for _ in range(TotalSamples):
PilotMaxVoltage=max(PilotMaxVoltage,PilotAnalogVoltage())
delaytime=PositivePulseTimeWidth-(time()-SampleStartTime)
if delaytime<0:
SamplesTooSlow+=1
MaxHowMuchTooSlow=max(MaxHowMuchTooSlow,-delaytime)
else:
sleep(delaytime)
SampleStartTime=time()
# TODO: need to re-do in some quicker compiled language, this is way too slow.
# or figure out the probability that the high value is not missed and how many samples are needed for that??
# currently the above way does miss some. is it okay since it is rare and will only do another main loop and that is not too long of a delay??
# what about premature disengagement thoug? yes, 3X is definitely way too short to not deal with the low sample rate. actually, 9V case seems "okay" with 6x, but 6V case still picks up a lot of 0 cases, the logic below doesn't actually care about the 0V error case, so it just lets it slide
# need to come up with a c library that is faster to do the loop, or see if the gpiozero mcp3008 implementation is faster somehow, or look at the SPI frequency, or just run the above loop longer and hope for the best.
#logger.info("Pilot max voltage: "+str(PilotMaxVoltage)+', percent pilot samples too slow='+str(SamplesTooSlow/TotalSamples*100))
#logger.info('MaxHowMuchTooSlow='+str(MaxHowMuchTooSlow)+' ('+str(MaxHowMuchTooSlow/PositivePulseTimeWidth*100)+'%)')
# TODO: need to do some other checking for SWCAN active because if so, then PilotMaxVoltage is not the way to know if it is actually connected
if (PilotMaxVoltage < PilotVoltageInserted+PilotVoltageInsertedTolerance) and (not Proximity): # SWCAN and J1772 both begin with 12VDC and transition to PilotVoltageInserted
if (time()>ProximityLostTime+15): # wait at least 15 seconds after the plug was removed to start looking for proximity again
if ProximityCheckStartTime==-1:
ProximityCheckStartTime=time()
logger.info('charge cable inserted but car may not be ready yet') # or was already inserted, but finally finished waiting 15 seconds
GUI.StatusAdd(BigText='Cable Inserted') # this will be shown for at least 3 seconds because we do that next before moving forward
GUI.StatusAdd(SmallText='Confirming Stable Connection')
elif time()>ProximityCheckStartTime+3: # proximity must be maintained for at least 3 seconds
Proximity=True
ReInsertedMessagePrinted=False
ProximityCheckStartTime=-1
logger.info("stable proximity confirmed")
if CurrentProximityAnalogVoltage > ProximityVoltageInserted-ProximityVoltageInsertedTolerance: # it is a Tesla connecting because the proximity can be measured
SWCAN_Relay.on()
logger.info("SWCAN relay energized")
else:
StateB=True
logger.info("NOT a tesla, SWCAN relay NOT energized")
GUI.StatusAdd(BigText='Cable Inserted, Car Idle', MinDisplayTime=1.5)
elif not ReInsertedMessagePrinted:
logger.info("plug re-inserted in less than 15 seconds, waiting")
# TODO: need to add this waiting logic to the car unit as well, so that both wall and car unit are in sync and start measuring energy delivery at the same time.
GUI.StatusAdd(BigText='Waiting')
ReInsertedMessagePrinted=True
logger.info("Pilot max voltage: "+str(PilotMaxVoltage)+', Proximity Voltage: '+str(CurrentProximityAnalogVoltage))
elif (
(
(SWCAN_Relay.is_lit and (CurrentProximityAnalogVoltage < ProximityVoltageInserted-ProximityVoltageInsertedTolerance*2)) # SWCAN
or
(PilotMaxVoltage > PilotVoltageInserted+PilotVoltageInsertedTolerance*2) # J1772
)
and
(not Proximity)
and
(
ProximityCheckStartTime != -1 # inserted after waiting 15 seconds from previous removal, but removed before 3 second hold completed
or
ReInsertedMessagePrinted # inserted before waiting 15 seconds from previous removal, but removed before waiting for the 15 seconds to complete
)
):
ProximityLostTime=time()
ReInsertedMessagePrinted=False
ProximityCheckStartTime=-1
logger.info("plug was removed before stable proximity confirmed")
logger.info("Pilot max voltage: "+str(PilotMaxVoltage)+', Proximity Voltage: '+str(CurrentProximityAnalogVoltage))
GUI.StatusReset(BigText='Cable Removed', MinDisplayTime=3)
elif (
(
(SWCAN_Relay.is_lit and (CurrentProximityAnalogVoltage < ProximityVoltageInserted-ProximityVoltageInsertedTolerance*2)) # SWCAN
or
(PilotMaxVoltage > PilotVoltageInserted+PilotVoltageInsertedTolerance*2) # J1772
)
and
(Proximity)
):
Proximity=False
StateB=False
StateC=False
ProximityLostTime=time()
DataLogger.close()
logger.info("plug removed")
logger.info("Pilot max voltage: "+str(PilotMaxVoltage)+', Proximity Voltage: '+str(CurrentProximityAnalogVoltage))
GUI.StatusReset(BigText='Cable Removed', MinDisplayTime=3)
# make twc ready to charge again
Meter.startTWC()
# can't reset these immediately when in manual pay because need to be able to give the final invoice
# instead, will wait for the final invoice to be paid or the time/credit to expire to do the reset.
# but we will reset immediately now when disconnecting from auto pay
if not ManualPayActive:
Meter.ChargeStartTime=-1 # stop counting session time
GUI.ChargeTimeText=FormatTimeDeltaToPaddedString(timedelta(seconds=0)) # and reset the session time displayed
Meter.reset()
if SWCAN_Relay.is_lit: # SWCAN was in use on the pilot
SWCAN_Relay.off()
logger.info("turned off SWCAN relay")
logger.info("\n\n\n")
# provide more information about the status if connected and not using SWCAN
if Proximity and not SWCAN_Relay.is_lit:
if StateB and (PilotMaxVoltage < PilotVoltageInsertedAndCarReady+PilotVoltageInsertedAndCarReadyTolerance):
GUI.StatusAdd(BigText='Cable Inserted, Ready To Charge', MinDisplayTime=1.5)
StateC=True
StateB=False
logger.info('Cable Inserted And Car Ready To Charge')
logger.info("Pilot max voltage: "+str(PilotMaxVoltage)+', Proximity Voltage: '+str(CurrentProximityAnalogVoltage))
elif StateC and (PilotMaxVoltage > PilotVoltageInsertedAndCarReady+PilotVoltageInsertedAndCarReadyTolerance*2):
if ManualPayState!='CreditExhausted': # don't show this message if credit was exhausted and that is why it is stopped.
GUI.StatusAdd(BigText='Cable Inserted, Car Idle', MinDisplayTime=1.5) # car not ready and/or TeslaTab J1772 adapter plugged into charge cable but not car
StateC=False
StateB=True
logger.info('charge cable still inserted but car no longer ready')
logger.info("Pilot max voltage: "+str(PilotMaxVoltage)+', Proximity Voltage: '+str(CurrentProximityAnalogVoltage))
if ManualPayActive and ExpiryHeight is not None: # do some things if ManualPayActive and there is an invoice accepted no matter if Proximity or not
# note, this is accurate to the nearest block. that is why a different time format is used to display
# TODO: figoure out if this wastes too much bandwidth making the block height call every loop iteration, should it only be done once per minute instead? if so, should this be moved to another thread?
# TODO: verify that this calculation gets more accurate closer to the expiry time because there are less blocks remaining?
HTLCBlocksRemaining=ExpiryHeight-lnd.get_best_block().block_height-BufferBlocks
HTLCTimeRemaining=HTLCBlocksRemaining*10*60 #10minutes/block*60seconds/minute
GUI.HTLCTimeRemainingText=TimeRounder(timedelta(seconds=HTLCTimeRemaining))
if ((Proximity or ManualPayActive) and Meter.MeasurementStarted) and not Meter.PowerKilled:
# measure energy for both autopay and manual pay sessions if plugged in and power is ON
# AND make sure it was successful
if not Meter.MeasureEnergyChange():
raise Exception('could not get updated energy measurement')
if Proximity and not ManualPayActive: # Connected for AutoPay
if not AutoPayActive: # cancel manual pay and start measurements when AutoPay has first started
# in autopay mode this is started AFTER the charge cable is plugged in
Meter.startMeasurementsNow()
DataLogger=LogData(Meter,GUI)
logger.debug('autopay started, canceling initial manual pay deposit invoice')
TheInitialDepositInvoice.cancel()
TheInitialDepositInvoice=None
ExpiryHeight=None
# get rid of manual pay stuff on the GUI
GUI.QRLink=None
GUI.InvoiceExpirationText=''
AutoPayActive=True
if not SWCAN_Relay.is_lit and not Meter.PowerKilled:
logger.debug('connected for autopay (no manual payment) but not a tesla, so can not autopay, need to kill power')
GUI.StatusAdd(BigText='Stopped Charging', SmallText='Vehicle Did Not Make Payment')
Meter.ChargeStartTime=-1 #makes stop counting charge time even through there is still proximity, but don't reset the counter yet
Meter.stopTWC()
elif Meter.PowerKilled:
# waiting for charge cable to be removed after stopping the charge while plugged in
pass
else:
if OfferAccepted:
if PendingInvoice:
#check to see if the current invoice has been paid
try:
if OutstandingInvoice.state==1: # 'SETTLED'
Meter.AddSettledPayment(OutstandingInvoice.value)
PendingInvoice=False
Meter.InitialInvoice=False #reset every time just to make the logic simpler
logger.info("payment received, time since last payment received="+str(time()-LastPaymentReceivedTime)+"s")
DataLogger.LogTabularDataAndMessages()
LastPaymentReceivedTime=time()
# assumes payments don't get made in less than 3.5 seconds (2 seconds for this message, 1 second for payment requested, and 0 to 0.5 seconds to blank)
# also, show SmallText for 5 seconds if we don't need to send another invoice right now (and assume we won't need to for another 5 seconds)
# or for 2 seconds if we know we know we need to send another invoice right now
# TODO: need to check and see if the LND GRPC gives an official time instead of this one.
GUI.StatusAdd(SmallText=RoundAndPadToString(OutstandingInvoice.value,0)+' sat Payment Received - ' + FullDateTimeString(datetime.now()), MinDisplayTime=5-(((Meter.EnergyCredit)<WhoursPerPayment*2*0.90))*3)
GUI.StatusAdd(SmallText='') # blank the previous message after its DisplayTime is over
except:
logger.exception("tried checking the current invoice's payment status but there was probably a network connection issue")
sleep(.25)
#now that the pending invoices have been processed, see if it's time to send another invoice
#adjust multiplier to decide when to send next invoice. can really send as early as possible because car just waits until it's really time to make a payment.
#was 0.5, but higher is probably really better because don't know how long the lightning network payment routing is actually going to take.
#send payment request 2*90% ahead of time so the buyer can have it ready in case they have a poor internet connection and want to pay early to avoid disruptions.
#note, because below 1% error is allowed, this test may actually not have much meaning considering over the course of a charging cycle
#the total error may be larger than an individual payment amount, so Meter.EnergyCredit is likely less than 0 and therefor
#a new invoice will just be sent right after the previous invoice was paid, rather than waiting.
# TODO: rework this in terms of sat because in the future with variable rates like GRID, will not be able to have
# a known energy amount that has been prepaid for, instead need to just have a fixed prepayment amount in sat.
# also move this test into Meter as a boolean so above we don't have to repeat the formula above when deciding how
# long to display the payment received message.
if ((Meter.EnergyCredit)<WhoursPerPayment*2*0.90) and not PendingInvoice:
while True:
try:
logger.info("trying to get a new invoice")
OutstandingInvoice=lnd.AddAndWatchInvoice(RequiredPaymentAmount,memo='Distributed Charge Energy Payment')
logger.info("got a new invoice")
break
except:
logger.exception("could not get a new invoice, trying again")
sleep(.25)
try:
logger.info("sending invoice for "+str(RequiredPaymentAmount)+" satoshis")
SWCAN_ISOTP.send(OutstandingInvoice.payment_request.encode()) #send the new invoice using CAN ISOTP
logger.info("sent new invoice for "+str(RequiredPaymentAmount)+" satoshis")
GUI.StatusAdd(SmallText=RoundAndPadToString(RequiredPaymentAmount,0)+' sat Payment Requested' , MinDisplayTime=1)
PendingInvoice=True
except:
logger.exception("could not send invoice, failing")
raise
elif PendingInvoice: #waiting for payment
#logger.info("waiting for payment, and limit not yet reached")
sleep(.25)
else:
#logger.info("waiting to send next invoice")
sleep(.25)
else:
#try to negotiate the offer
if (SWCANMessages.OfferResponseReceived is not None) and (SWCANMessages.OfferResponseReceived):
OfferAccepted=True
logger.info("buyer accepted rate")
GUI.StatusAdd(BigText='Charging', SmallText='Sale Terms Accepted', MinDisplayTime=2)
FirstRequiredPaymentAmount=1*RequiredPaymentAmount #sat, adjust multiplier if desire the first payment to be higher than regular payments.
while True:
try:
logger.info("trying to get a new (first) invoice")
OutstandingInvoice=lnd.AddAndWatchInvoice(FirstRequiredPaymentAmount,memo='Distributed Charge Energy Payment')
logger.info("got a new (first) invoice")
break
except:
logger.exception("could not get a new (first) invoice, trying again")
sleep(.25)
try:
logger.info("sending first invoice for "+str(FirstRequiredPaymentAmount)+" satoshis")
SWCAN_ISOTP.send(OutstandingInvoice.payment_request.encode())
logger.info("sent first invoice")
GUI.StatusAdd(SmallText=RoundAndPadToString(FirstRequiredPaymentAmount,0)+' sat Payment Requested' , MinDisplayTime=1)
PendingInvoice=True
except:
logger.exception("could not send first invoice, failing")
raise
elif (SWCANMessages.OfferResponseReceived is not None) and (not SWCANMessages.OfferResponseReceived):
OfferAccepted=False
logger.info("buyer rejected rate")
#GUI.StatusAdd(SmallText='Sale Terms Rejected')
elif (TimeLastOfferSent+1)<time(): # only send once per second
# provide the offer
SWCANMessages.send_sale_offer(Meter.RecentRate, RequiredPaymentAmount)
TimeLastOfferSent=time()
logger.info("provided an offer of "+RoundAndPadToString(Meter.RecentRate,2)+" satoshis/(W*hour) with a payment size of "+str(RequiredPaymentAmount)+" satoshis")
GUI.StatusAdd(SmallText='Provided Sale Terms Offer To Vehicle', MinDisplayTime=2)
LastPaymentReceivedTime=time() # fudged since no payment actually received yet, but want to still time since invoice sent, and need variable to be initialized.
else:
logger.info("waiting for buyer to accept or reject rate")
sleep(.25)
if (
# buyer must pay ahead 20% for all payments but the first payment (must pay after 80% has been delivered).
# also allow 1% error due to measurement error as well as transmission losses between the car and the wall unit.
# this error basically needs to be taken into consideration when setting the sale rate.
(((Meter.EnergyCredit)<WhoursPerPayment*0.20*1.01) and not Meter.InitialInvoice)
or
# buyer can go into debt 30% before the first payment, also allowing for 1% error as above, although that may be really needed for the first payment.
(((Meter.EnergyCredit)<-WhoursPerPayment*0.30*1.01) and Meter.InitialInvoice)
):
logger.info("buyer never paid, need to kill power, time since last payment received="+str(time()-LastPaymentReceivedTime)+"s, InitialInvoice="+str(Meter.InitialInvoice))
SMTPNotification('buyer never paid','')
GUI.StatusAdd(BigText='Power OFF', SmallText='Vehicle Did Not Make Payment')
DataLogger.LogTabularDataAndMessages()
Meter.stopTWC()
elif AutoPayActive:
logger.debug('just unplugged from an AutoPay session')
AutoPayActive=False
ManualPayState='GetNewInvoice'
ManualPayActive=True # not really true, but just as on startup, need to trick it to get to the right place and then it immediately changes this back
OfferAccepted=False
SWCANMessages.OfferResponseReceived=None
sleep(.075*3) # make this longer than the receive timeouts so that the buffers always get empty, otherwise there will be a reaction delay because the receiver is still processing old messages????
elif Proximity: # Connected or reconnected after making a Manual Payment <--------- TODO: find out what this does if already plugged in on startup and make smarter handeling of that startup case
if ManualPayState=='ReadyToInsertChargeCable':
logger.debug('charge cable inserted in manual pay mode')
GUI.InvoiceExpirationText='Remove Cable\nto receive a final invoice.\n\nAfter paying your held\ndeposits will be canceled\nand returned.\n\n'
ManualPayState='Charging'
elif ManualPayState=='Charging':
if CreditAndHTLCBlocksRemaining(KillPower=True):
if StateC or SWCAN_Relay.is_lit:
GUI.StatusAdd(BigText='Charging')
# TODO: need to add some periodic outputs here of energy, voltage, etc. to the log file
elif ManualPayState=='CreditExhausted':
# credit was exhausted and power killed, but the charge cable still not unplugged yet
GUI.StatusAdd(SmallText='Remove Cable to Reset')
elif ManualPayState=='WaitingForFinalPayment': # reconnected but did not pay final invoice, so cancel and resume session
logger.debug('charge cable re-inserted before settling final invoice.')
GUI.QRLink=None
logger.debug('canceling final invoice')
TheFinalInvoice.cancel()
TheFinalInvoice=None
GUI.InvoiceExpirationText='Remove Cable\nto receive a final invoice.\n\nAfter paying your held\ndeposits will be canceled\nand returned.\n\n'
ManualPayState='Charging'
elif ManualPayActive and ManualPayState=='ReadyToInsertChargeCable':
# ready to insert charge cable, but have not done so yet
pass
elif ManualPayActive: # unplugged from a ManualPay session
if ManualPayState=='Charging': # just inplugged
#generate a new invoice
FinalInvoiceAmount=int(GUI.EnergyCost) # use the value from the GUI from the last iteration instead of the very latest amount so that they match
TheFinalInvoice=lnd.AddAndWatchInvoice(value=FinalInvoiceAmount,memo='Distributed Charge Final Payment',expiry=HTLCTimeRemaining*2,InvoiceType='HOLD')
GUI.QRLink='lightning:'+TheFinalInvoice.payment_request
GUI.InvoiceExpirationText='Pay '+str(FinalInvoiceAmount)+' sat final invoice &\n your held deposits will be\ncanceled and returned'
GUI.StatusAdd(SmallText='Waiting For Final Payment')
ManualPayState='WaitingForFinalPayment'
elif ManualPayState=='WaitingForFinalPayment':
if TheFinalInvoice.state==3: # 'ACCEPTED'
Meter.AddHeldPayment(FinalInvoiceAmount)
logger.debug('settled final invoice, canceling initial deposit')
GUI.QRLink=None
GUI.InvoiceExpirationText=''
# there is more risk on the seller's end to cancel the initial deposit before settling the final invoice because they might not settle the final invoice if a power outage happens between these two steps.
# however it is only fair to take on that risk because the seller also could have a power outage and not cancel the initial deposit.
# this way the seller takes the risk of their hardware issues.
# LND should auto cancel the unsettled HOLD invoice if wall does not cancel it, and if LND also gets a power outage and does not auto cancel, the sender should be able to take things on chain and get all of their funds back
# however, the seller wants to be nice about it if they can.
# also, in `CreditAndHTLCBlocksRemaining` the initial deposit is accepted and the final invoice is canceled
# the seller should to use a hold invoice for that too so that the seller can cancel it if the buyer sent payment to the final invoice near the last instant
# so that the seller doesn't accidentally claim BOTH payments (by letting LND automatically claim the final payment). this is to the seller's advantage, but again, they want to be nice so that the customer comes back
# since the customer already took a lot of risk on the buyer in the beginning of the charge session.
# TODO: consider making the final invoice expiry shorter and QR code for the final payment go away early like it does for the initial deposit to help avoid this edge case even more?
# another reason all this matters is because HTLCTimeRemaining is based on the estimated time when the invoice was generated, but that estimate can be farther off by the time the invoice expires !
# that is why the invoice expiry is 2*HTLCTimeRemaining for now to be conservative and since we can cancel the unpaid final invoice, it isn't a big deal though, so maybe keeping a single final invoice is actually fine...
TheInitialDepositInvoice.cancel()
Meter.CancelHeldPayment(DepositAmount)
TheFinalInvoice.settle()
Meter.AddSettledPayment(FinalInvoiceAmount,Held=True)
TheInitialDepositInvoice=None
ExpiryHeight=None
GUI.StatusAdd(SmallText='Final payment of '+RoundAndPadToString(FinalInvoiceAmount,0)+' sat received & initial '+RoundAndPadToString(DepositAmount,0)+' sat deposit canceled!') # 20 second sleep below, so don't need to set MinDisplayTime
GUI.HTLCTimeRemainingText=''
ManualPayState='GetNewInvoice'
# don't change anything for a while so the above message can be read clearly
Meter.ChargeStartTime=-1 # makes stop counting charge time, but don't reset the counter yet
GUI.ChargeStartTime=Meter.ChargeStartTime # need to force this early since sleeping here
sleep(20)
elif not CreditAndHTLCBlocksRemaining(KillPower=False):
# time ran out while unplugged
pass
elif ManualPayState=='CreditExhausted': # or the Max Session Time Reached # TODO: make this allow another payment to be made to continue the session and also show the right SmallStatus message if exhausted
# TODO: consolidate this with the elif statement above? no, because we need to get to this after CreditExhausted while plugged in.
# don't need to update BigText because it is updated in the next iteration
ManualPayState='GetNewInvoice'
elif ManualPayState=='GetNewInvoice':
logger.debug('getting a new manual pay invoice')
# clear tracked/displayed values
# manual pay and auto pay both already stoped counting charge time.
# auto pay already reset the Meter and GUI.ChargeTimeText, but manual
# pay didn't, so need to (re)do it here in case last charge session was a manual pay session
GUI.ChargeTimeText=FormatTimeDeltaToPaddedString(timedelta(seconds=0))
Meter.reset()
TheInitialDepositInvoice=lnd.AddAndWatchInvoice(value=DepositAmount,memo='Distributed Charge Energy Payment Deposit',expiry=int(60*10),InvoiceType='HOLD',cltv_expiry=cltv_expiry)
FinalInvoiceAmount=0
TheFinalInvoice=None
TheDecodedInvoice = decode(TheInitialDepositInvoice.payment_request)
GUI.QRLink='lightning:'+TheInitialDepositInvoice.payment_request
GUI.StatusAdd(BigText='Make a Manual Deposit Or\nInsert Cable Into AutoPay Enabled Car', SmallText='Power OFF - Waiting For Payment')
ManualPayState='WaitingForInitialPayment'
ManualPayActive=False
else:
raise Exception('should not get here')
else: # waiting for a ManualPay OR AutoPay session
if ManualPayState == 'WaitingForInitialPayment':
InvoiceTimeRemaining=ceil(TheDecodedInvoice.timestamp+TheDecodedInvoice.expiry_time-time())
GUI.InvoiceExpirationText = (
RoundAndPadToString(DepositAmount,0)+' sat'
+
'\n'
+
RoundAndPadToString(PrePayWhours/1000,0)+' kW*hour'
+
'\n'
+
# TODO: off by a few blocks??? need to check. also need to figure out correct BufferBlocks to use.
# for some reason the actual cltv_expiry is coming out 3 blocks greater than what was specified in the invoice request? maybe LND is adding a little more buffer?
# should this instead just use ExpiryHeight?? NO, don't know ExpiryHeight until after a payment is accepted and here we are advertizing the minimum time their
# deposit will be good for if they make it.
TimeRounder(timedelta(seconds=(cltv_expiry-BufferBlocks)*600))
)
if InvoiceTimeRemaining>0 and TheInitialDepositInvoice.state==3: # 'ACCEPTED'
Meter.AddHeldPayment(DepositAmount)
logger.debug('initial deposit accepted')
# TODO: decide how to wait until htlcs is defined, check for more than one htlc?????
ExpiryHeight=TheInitialDepositInvoice.htlcs[0].expiry_height
ManualPayState='InvoicePaid'
GUI.QRLink=None
GUI.InvoiceExpirationText=''
GUI.StatusAdd(BigText='Power ON', SmallText='Deposit of '+RoundAndPadToString(DepositAmount,0) +' sat made\nYou may now plug in the charge cable.', MinDisplayTime=3)
# in manual pay mode this is started BEFORE the charge cable is plugged in
Meter.startMeasurementsNow()
DataLogger=LogData(Meter,GUI)
elif InvoiceTimeRemaining<=0:
ManualPayState='GetNewInvoice'
ManualPayActive=True # not really true, but just as on startup, need to trick it to get to the right place and then it immediately changes this back
elif InvoiceTimeRemaining<QRCodeHideDelay:
logger.debug('initial invoice expired, a new one is coming up soon')
GUI.QRLink=None
GUI.InvoiceExpirationText='Please wait for a\nnew deposit invoice'
else:
# wait
pass
elif ManualPayState == 'InvoicePaid':
logger.debug('initial deposit made, ready to insert charge cable for manual pay mode')
ManualPayActive=True
GUI.QRLink=None
ManualPayState='ReadyToInsertChargeCable'
# pass values to the GUI
GUI.Volts=Meter.Volts
GUI.Amps=Meter.Amps
GUI.Power=Meter.Power
GUI.EnergyDelivered=Meter.EnergyDelivered
GUI.EnergyCost=Meter.EnergyCost
GUI.CreditRemaining=Meter.CreditRemaining
GUI.RecentRate=Meter.RecentRate
GUI.RequiredPaymentAmount=RequiredPaymentAmount
GUI.ChargeStartTime=Meter.ChargeStartTime
GUI.MaxAmps=ConfigFile['Seller']['MaxAmps']
GUI.SettledPayments=Meter.SettledPayments
GUI.CanceledPayments=Meter.CanceledPayments
GUI.HeldPayments=Meter.HeldPayments
################################################################
# 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