-
Notifications
You must be signed in to change notification settings - Fork 8
/
Copy pathgrid-seller.py
executable file
·548 lines (334 loc) · 20.4 KB
/
grid-seller.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
#!/usr/bin/env python3
###############################################################################
###############################################################################
# Copyright (c) 2024, Andy Schroder
# See the file README.md for licensing information.
###############################################################################
###############################################################################
print('')
print('')
print('')
print('')
################################################################
# import modules
################################################################
from ekmmeters import SerialPort,V4Meter #needs to go first because it has some funky time module that is imported, otherwise need to only import what is used instead of using * --- UPDATE: now importing only what is needed too.
from time import sleep,time
from datetime import datetime,timedelta
from lndgrpc import LNDClient
from dc.GUI import GUIThread as GUI
from dc.common import StatusPrint,UpdateVariables,TheDataFolder,WaitForTimeSync,TimeStampedPrintAndSmallStatusUpdate
from yaml import safe_load
from helpers2 import FormatTimeDeltaToPaddedString,RoundAndPadToString,TimeStampedPrint,FullDateTimeString,SetPrintWarningMessages
from gpiozero import LED
import sys,json,socket,ssl
from pathlib import Path
from cryptography.x509 import load_pem_x509_certificate
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from dc.SocketHelpers import SendMessage,ReceiveMessage
from secrets import token_bytes
################################################################
# TODO:
# - make more constraints on payments because if PrePayment and RequiredPaymentAmount are too small for the current power level, there is a chance that the invoice won't be requested quick enough and then paid quick enough and then power will be shut off even when the buyer wanted to pay invoices as soon as they were received.
################################################################
# import values from config file
################################################################
with open(TheDataFolder+'Config.yaml', 'r') as file:
ConfigFile=safe_load(file)
# assign some shorter variable names
LocalMeterNumber=ConfigFile['Seller']['LocalMeterNumber']
LocalMeterScalingFactor=ConfigFile['Seller']['LocalMeterScalingFactor']
# need to use the function so that it can modify the value inside the imported module so that everything that imports TimeStampedPrint will get this value.
SetPrintWarningMessages(ConfigFile['Seller']['PrintWarningMessages'])
################################################################
TimeStampedPrint('startup!') #needs to be after loading configuration since TimeStampedPrint needs to know the value of PrintWarningMessages
TimeStampedPrint('configuration loaded')
################################################################
# launch the GUI before anything gets printed to standard output
GUI.start() #starts .run() (and maybe some other stuff?)
WaitForTimeSync(GUI)
# uncomment to add a pause if doing a screen record and need time to organize windows to the right size before anything gets printed to standard output.
#sleep(120)
################################################################
from dc.RateFunctions import GenerateCurrentSellOfferTerms,MeterMonitor #uses TimeStampedPrint, so do this import away from the rest of the modules
################################################################
################################################################
# initialize variables
################################################################
OfferAccepted=False
PowerKilled=True
LastPaymentReceivedTime=0
RequiredPaymentAmount=-1
TimeLastOfferSent=time()
################################################################
################################################################
# read in certificates
################################################################
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(TheDataFolder+'/SSL/cert.pem', TheDataFolder+'/SSL/key.pem')
with open(TheDataFolder+'/SSL/cert.pem', "rb") as f:
cert_obj = load_pem_x509_certificate(f.read(),default_backend()) # can't figure out how to extract this from the context object created above, so just read it in twice. hard to follow the source code because it is a python module written in c(++?, https://raw.githubusercontent.com/python/cpython/main/Modules/_ssl.c). it seems like python doesn't have access to all variables in the module.
h=cert_obj.fingerprint(hashes.SHA256())
TimeStampedPrint('fingerprint client needs to trust: '+h.hex())
################################################################
################################################################
# initialize GPIO
################################################################
Contactor = LED(27)
#should be off on boot, but just make sure it is off on startup in case the script crashed/killed with it on and is being restarted without rebooting.
Contactor.off()
GUI.BigStatus='Power OFF'
################################################################
################################################################
#initialize the LND RPC
################################################################
lnd = LNDClient(ConfigFile['Seller']['LNDhost'], macaroon_filepath=TheDataFolder+'/lnd/invoice.macaroon',cert_filepath=TheDataFolder+'/lnd/tls.cert')
################################################################
################################################################
#initialize the RS-485 port and meter
################################################################
#ekm_set_log(ekm_print_log)
MeterPort = SerialPort(ConfigFile['Seller']['RS485Port'])
MeterPort.initPort()
RawMeter = V4Meter(LocalMeterNumber)
RawMeter.attachPort(MeterPort)
################################################################
Meter=MeterMonitor(RawMeter,LocalMeterScalingFactor,ForceInitialRead=True)
UpdateVariables(Meter,GUI,'seller')
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) as sock:
sock.bind(('0.0.0.0', 4545))
sock.listen(5) # TODO: figure out why this is needed.
TimeStampedPrintAndSmallStatusUpdate('Waiting for buyer to establish communication link.',GUI)
with context.wrap_socket(sock, server_side=True) as ssock:
ClientAuthenticated=False
conn, addr = ssock.accept()
server_cert = conn.getpeercert(binary_form=True);
with conn:
TimeStampedPrintAndSmallStatusUpdate('Communication link established.',GUI)
TimeStampedPrint(f"Connected by {addr}")
InitialInvoice=True
OfferAccepted=False
PowerKilled=True
PaymentsReceived=0
SalePeriods=0
while True:
if (PowerKilled and GUI.ChargeStartTime!=-1):
GUI.ChargeStartTime=-1 #makes stop counting charge time even through there is still proximity. might need to rework this since proximity isn't relevant in the GRID application?????????
else:
NewMessage = ReceiveMessage(conn)
if not ClientAuthenticated:
if not NewMessage:
break
if NewMessage == "I don't trust you.":
TimeStampedPrint('client thinks there is a man in the middle attack and will disconnect')
break
elif NewMessage == ConfigFile['Seller']['RemoteClientIdentifier']:
ClientAuthenticated=True
GUI.Connected=ClientAuthenticated
SendMessage('client is allowed to connect',conn)
TimeStampedPrintAndSmallStatusUpdate('Buyer Authenticated',GUI)
else:
SendMessage('Client Identifier not allowed, disconnecting',conn)
break
else: #client is authenticated
#after authenticating, everything should be JSON unless ACK
MessageAlreadySentThisLoopIteration=False
#try to negotiate the offer
if (NewMessage=='ACK') and (not OfferAccepted or (SellOfferTerms['OfferStopTime']-time())<30):
#provide the offer
#prepare to compute energy paid for and also to map the rate profile to the actual offer duration
#on the buyer's side, they put the RequiredRateInterpolator object inside the SellOfferTerms dictionary, but that is fine because it's just their personal dictionary,
#but since this one will be converted to JSON and send remotely, don't want to do that.
NewSellOfferTerms,NewRequiredRateInterpolator=GenerateCurrentSellOfferTerms()
# add some keys and values.
NewSellOfferTerms['MessageType']='SellOfferTerms'
NewSellOfferTerms['MessageReference']=token_bytes(32).hex() #random data to make the message unique.
NewSellOfferTerms['MeterNumber']=LocalMeterNumber # integer, Confirmation that the buyer and seller are talking about the same meter.
if not OfferAccepted:
NewSellOfferTerms['SellOfferTermsType'] = 'Initial'
if 'InitialDeposit' in NewSellOfferTerms['Payments']:
# sat, integer, this is basically a minimum amount of business the seller is willing to do.
# If the InitialDeposit is less than the PrePayment then it doesn't have any impact and the PrePayment will be used for the first payment.
NewSellOfferTerms['Payments']['InitialDeposit']=max(NewSellOfferTerms['Payments']['InitialDepositMultiple']*NewSellOfferTerms['Payments']['MinPayment'],NewSellOfferTerms['Payments']['PrePayment'])
else:
#if no initial deposit defined, then set it to the prepayment amount.
NewSellOfferTerms['Payments']['InitialDeposit']=NewSellOfferTerms['Payments']['PrePayment']
LastPaymentReceivedTime=time() #fudged since no payment actually received yet, but want to still time since invoice sent, and need variable to be initialized.
elif (SellOfferTerms['OfferStopTime']-time())<30: #note: this is checking the old offer
NewSellOfferTerms['SellOfferTermsType'] = 'Renewal'
else:
raise Exception('should never get here')
# remove some keys and values that aren't needed buy the buyer. don't want to confuse them or provide them with unnecessary information
NewSellOfferTerms['Payments'].pop('InitialDepositMultiple')
# anything else to add ????
GUI.SmallStatus='Provided '+NewSellOfferTerms['SellOfferTermsType']+' Offer'
TimeStampedPrint(NewSellOfferTerms,prettyprintprepend='NewSellOfferTerms',prettyprint=True)
SendMessage(json.dumps(NewSellOfferTerms),conn)
OfferAccepted=False
MessageAlreadySentThisLoopIteration=True
elif NewMessage=='ACK' and OfferAccepted:
if PendingInvoice:
#check to see if the current invoice has been paid
try:
TimeStampedPrint("trying to check the current invoice's payment status")
OutstandingInvoiceStatus=lnd.lookup_invoice(OutstandingInvoice.r_hash)
TimeStampedPrint("checked the current invoice's payment status")
if OutstandingInvoiceStatus.settled:
Meter.EnergyPayments+=OutstandingInvoiceStatus.value
PaymentsReceived+=1
PendingInvoice=False
if InitialInvoice:
GUI.ChargeStartTime=datetime.now()
Contactor.on()
PowerKilled=False
GUI.BigStatus='Power ON'
InitialInvoice=False #reset every time just to make the logic simpler
TimeStampedPrint("payment received, time since last payment received="+str(time()-LastPaymentReceivedTime)+"s")
StatusPrint(Meter,GUI,SellOfferTerms,PaymentsReceived,SalePeriods)
LastPaymentReceivedTime=time()
GUI.SmallStatus='Payment Received - '+FullDateTimeString(datetime.now()) #need to check and see if the LND GRPC gives an official time instead of this one.
except:
TimeStampedPrint("tried checking the current invoice's payment status but there was probably a network connection issue")
sleep(.25)
raise
# now that the pending invoices have been processed, see if it's time to send another invoice, or shutdown power if invoices haven't been paid in a timely manner.
# time to send another invoice #
# adjust multiplier to decide when to send next invoice. can really send as early as possible because the buyer just waits until it's really time to make a payment.
# was intially using 0.5, but higher is probably really better because don't know how long the lightning network payment routing is actually going to take.
# so, 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.
if ((Meter.EnergyPayments-Meter.EnergyCost)<SellOfferTerms['Payments']['PrePayment']*2*0.60) and not PendingInvoice:
try:
TimeStampedPrint("trying to get an invoice")
OutstandingInvoice=lnd.add_invoice(RequiredPaymentAmount)
TimeStampedPrint("got an invoice")
SellerInvoice = {
'MessageType': 'SellerInvoice',
'Invoice': OutstandingInvoice.payment_request, #lightning invoice string
#don't need a MessageReference because the Invoice should be unique
}
SendMessage(json.dumps(SellerInvoice),conn)
MessageAlreadySentThisLoopIteration=True
TimeStampedPrint("sent new invoice for "+str(RequiredPaymentAmount)+" satoshis")
GUI.SmallStatus='Payment Requested'
PendingInvoice=True
HeartBeatsSinceLastStatusPrint=0
except:
TimeStampedPrint("tried getting a new invoice but there was probably a network connection issue")
sleep(.25)
raise
elif PendingInvoice: #waiting for payment
TimeStampedPrint("waiting for payment, and limit not yet reached")
# TODO: add StateMessage sends here.
pass
else:
TimeStampedPrint("waiting to send next invoice")
HeartBeatsSinceLastStatusPrint+=1
if HeartBeatsSinceLastStatusPrint==6:
StatusPrint(Meter,GUI,SellOfferTerms,PaymentsReceived,SalePeriods)
HeartBeatsSinceLastStatusPrint=0
sleep(10) # TODO: make this sleep time more intellegent based on expected max power level and the payment size.
elif NewMessage=='':
TimeStampedPrint('seems like the client disconnected')
break
else:
NewMessageJSON=json.loads(NewMessage)
TimeStampedPrint(NewMessageJSON,prettyprintprepend='New JSON Received',prettyprint=True)
if NewMessageJSON['MessageType']=='BuyerResponse':
if OfferAccepted is True:
# extra messages can come in because HeartBeats are used to "bump" the loop. if the duplicate message is not ignored, then the offer will be
# accepted many times and new invoices created each time, but the invoices can never be checked because they keep changing (probably because
# old ones are forgotten and there is no invoice queue???), so the logic above can never look at the right one and see if it was paid.
TimeStampedPrint("buyer already accepted rate, ignoring duplicate message")
else:
if NewMessageJSON['AcceptedSellerRate']==True:
#buyer accepted the rate
OfferAccepted=True
TimeStampedPrint("buyer accepted rate")
#GUI.BigStatus=''
GUI.SmallStatus='Sale Terms Accepted'
HeartBeatsSinceLastStatusPrint=0
SalePeriods+=1
SellOfferTerms=NewSellOfferTerms
RequiredRateInterpolator=NewRequiredRateInterpolator
Meter.SellOfferTerms=SellOfferTerms
Meter.RequiredRateInterpolator=RequiredRateInterpolator
if (
('DesiredPaymentSize' in NewMessageJSON)
and
#see https://docs.python.org/3/reference/expressions.html#comparisons for why this works.
(SellOfferTerms['Payments']['MinPayment']<NewMessageJSON['DesiredPaymentSize']<SellOfferTerms['Payments']['MaxPayment'])
):
#buyer's desired payment size is within limits, so use it except for the intial invoice.
RequiredPaymentAmount=NewMessageJSON['DesiredPaymentSize']
else:
#buyer did not specify a desired payment size or it was out of the allowable limits, so use the smallest payment size allowable.
RequiredPaymentAmount=SellOfferTerms['Payments']['MinPayment']
GUI.RequiredPaymentAmount=RequiredPaymentAmount
GUI.MaxAmps=SellOfferTerms['Electrical']['Current']['Maximum']
try:
if SellOfferTerms['SellOfferTermsType']=='Initial':
TimeStampedPrint("trying to get first invoice")
OutstandingInvoice=lnd.add_invoice(SellOfferTerms['Payments']['InitialDeposit'])
TimeStampedPrint("got first invoice")
SellerInvoice = {
'MessageType': 'SellerInvoice',
'Invoice': OutstandingInvoice.payment_request, #lightning invoice string
#don't need a MessageReference because the Invoice should be unique
}
SendMessage(json.dumps(SellerInvoice),conn)
MessageAlreadySentThisLoopIteration=True
TimeStampedPrint("sent first invoice for "+str(SellOfferTerms['Payments']['InitialDeposit'])+" satoshis")
PendingInvoice=True
except:
# probably the protocol will get stuck if this exception is caught because the buyer won't re-send the acceptance message.
# maybe need buyer to keep repeating the acceptance message until the seller sends an acknowledgement???
TimeStampedPrint("tried getting a new (first) invoice but there was probably a network connection issue")
sleep(.25)
raise
else:
OfferAccepted=False
TimeStampedPrint("buyer rejected rate")
#GUI.SmallStatus='Sale Terms Rejected'
sleep(5) #don't loop and waste CPU time, instead wait and give the buyer a chance to change their mind.
else:
#TODO: decide if need to take better action here. if not, everything just stalls? currently just raising an exception because need to decide what to do.
raise Exception("some other message was received")
if not MessageAlreadySentThisLoopIteration: #other logic makes this not necessary, but this can speed things up especially since rejected offers have a long sleep time.
SendMessage('HeartBeat',conn) #send heartbeat message to see how stable the connection is and also to "bump" the loop on since currently having read blocks on the socket.
if (
(not PowerKilled) # energy has already started being delivered.
and
(
(
((Meter.EnergyPayments-Meter.EnergyCost)<SellOfferTerms['Payments']['PrePayment']*0.20) # buyer didn't pay ahead 20% for all payments (they must pay after 80% has been delivered).
)
or (SellOfferTerms['OfferStopTime']<time()) # current sale period has expired without being renewed.
)
):
TimeStampedPrint("buyer never paid or didn't renew payment terms, need to kill power, time since last payment received="+str(time()-LastPaymentReceivedTime)+"s EnergyPayments="+str(Meter.EnergyPayments)+' EnergyCost='+str(Meter.EnergyCost))
Contactor.off()
PowerKilled=True
GUI.BigStatus='Power OFF'
GUI.SmallStatus='Customer Did Not Make Payment'
elif (not PowerKilled) and (Meter.Amps>SellOfferTerms['Electrical']['Current']['Maximum']):
Contactor.off()
PowerKilled=True
TimeStampedPrint('buyer tried consuming too much current, shutting down before blowing the circuit breaker, PowerKilled='+str(PowerKilled))
GUI.BigStatus='Power OFF'
GUI.SmallStatus='Current draw greater than allowed.'
else:
# everything is okay, continue to allow energy flow
pass
sleep(0.075)
except (KeyboardInterrupt, SystemExit):
GUI.stop()
GUI.join() #for some reason if this is not used, python tries too quit before the stop command is received by the thread and it gracefully shutdown and then it takes longer for tk to timeout and close the interpreter?
TimeStampedPrint("quitting")
except:
raise
finally:
# the state should be restored to off when python is stopped, but explicitly set to off to be sure.
Contactor.off()
TimeStampedPrint("turned off relay\n\n\n")