Repository navigation
Expand file tree
/
Copy pathoperateShutters.py
More file actions
executable file
·810 lines (702 loc) · 38.2 KB
/
Copy pathoperateShutters.py
File metadata and controls
executable file
·810 lines (702 loc) · 38.2 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
#!/usr/bin/python3
# -*- coding: utf-8 -*-
import sys, re, argparse
# ── Platform detection & stubs for Windows development ──────────────────────
WINDOWS = (sys.platform == "win32")
if WINDOWS:
import types
# fcntl is Unix-only (used for lock files)
_fcntl = types.ModuleType("fcntl")
_fcntl.LOCK_EX = 0
_fcntl.LOCK_NB = 0
_fcntl.lockf = lambda *a, **kw: None
sys.modules["fcntl"] = _fcntl
# GPIO stubs for Windows development
_pigpio = types.ModuleType("pigpio")
_pigpio.OUTPUT = 0
class _FakePulse:
def __init__(self, *a, **kw): pass
_pigpio.pulse = _FakePulse
class _FakePi:
connected = True
def wave_add_new(self): pass
def set_mode(self, *a): pass
def wave_add_generic(self, *a): pass
def wave_create(self): return 0
def wave_send_once(self, *a): pass
def wave_tx_busy(self): return False
def wave_delete(self, *a): pass
def stop(self): pass
_pigpio.pi = _FakePi
sys.modules["pigpio"] = _pigpio
_lgpio = types.ModuleType("lgpio")
_lgpio.TX_WAVE = 1
class _FakeLgPulse:
def __init__(self, *a, **kw): pass
_lgpio.pulse = _FakeLgPulse
_lgpio.gpiochip_open = lambda *a: 0
_lgpio.gpio_claim_output = lambda *a: 0
_lgpio.tx_wave = lambda *a: 0
_lgpio.tx_busy = lambda *a: False
_lgpio.gpio_free = lambda *a: 0
_lgpio.gpiochip_close = lambda *a: 0
sys.modules["lgpio"] = _lgpio
import fcntl
import os
import locale
import time
import datetime
import ephem
import socket
# ── Pi model detection & GPIO library selection ─────────────────────────────
# Pi 5 uses the RP1 southbridge chip which is incompatible with pigpio.
# We use lgpio (which works via /dev/gpiochip*) on Pi 5, and pigpio elsewhere.
from pi_model import detect_pi5
IS_PI5 = False if WINDOWS else detect_pi5()
LGPIO_CHIP = 4 # gpiochip number for lgpio (Pi 5): 4 on older kernels, 0 on newer
if IS_PI5:
import lgpio
else:
import pigpio
import signal, atexit, traceback
import logging, logging.handlers
import threading
try:
from config import MyConfig
from config import SetupLogger
from config import MyLog
from scheduler import Event
from scheduler import Schedule
from scheduler import Scheduler
from webserver import FlaskAppWrapper
from alexa import Alexa
from shutil import copyfile
from rf_backend import create_transmitter
except Exception as e1:
print("\n\nThis program requires the modules located from the same github repository that are not present.\n")
print("Error: " + str(e1))
sys.exit(2)
class Shutter(MyLog):
#Button values
buttonUp = 0x2
buttonStop = 0x1
buttonDown = 0x4
buttonProg = 0x8
class ShutterState: # Definition of one shutter state
position = None # as percentage: 0 = closed (down), 100 = open (up)
lastCommandTime = None # get using time.monotonic()
lastCommandDirection = None # 'up' or 'down' or None
def __init__(self, initPosition = None):
self.position = initPosition
self.lastCommandTime = time.monotonic()
def registerCommand(self, commandDirection):
self.lastCommandDirection = commandDirection
self.lastCommandTime = time.monotonic()
def __init__(self, log = None, config = None, rf_transmitter = None):
super(Shutter, self).__init__()
self.lock = threading.Lock()
self.transmitting = threading.Event()
if log is not None:
self.log = log
if config is not None:
self.config = config
if getattr(self.config, "TXGPIO", None) is not None:
self.TXGPIO=self.config.TXGPIO # 433.42 MHz emitter
else:
self.TXGPIO=4 # 433.42 MHz emitter on GPIO 4
self.RFBackend = getattr(self.config, "RFBackend", "raw_433").strip().lower()
self.rf_transmitter = rf_transmitter
if self.rf_transmitter is None:
try:
self.rf_transmitter = create_transmitter(
self.config,
is_pi5=IS_PI5,
pigpio_module=globals().get("pigpio"),
lgpio_module=globals().get("lgpio"),
lgpio_chip=LGPIO_CHIP,
)
except (RuntimeError, ValueError) as e:
self.FatalError(str(e))
self.frame = bytearray(7)
self.callback = []
self.movementCallback = []
self.movementStateList = {}
self.shutterStateList = {}
self.shutterStateLock = threading.Lock()
# Seed persisted positions, only for shutters
# that both still exist in [Shutters] and have a settled position
# recorded in [ShutterPositions], so getShutterState's lazy-init
# fallback only ever applies to shutters that have never settled.
for shutterId, position in self.config.ShutterPositions.items():
if shutterId in self.config.Shutters:
self.shutterStateList[shutterId] = self.ShutterState(position)
def getShutterState(self, shutterId, initialPosition = None):
with self.shutterStateLock:
if shutterId not in self.shutterStateList:
self.shutterStateList[shutterId] = self.ShutterState(initialPosition)
return self.shutterStateList[shutterId]
def getPosition(self, shutterId):
state = self.getShutterState(shutterId, 0)
return state.position
# Live-interpolated position while a move is in progress, for UI display
# only. Mirrors _simulateStop's elapsed-time/direction math (the same
# "how far did it get" estimate a MY-press stop would compute) but never
# mutates state or settles a position — internal logic that decides
# partial-move direction etc. keeps using getPosition()'s last-settled
# value, since an in-flight estimate isn't precise enough to act on.
def getDisplayPosition(self, shutterId):
state = self.getShutterState(shutterId, 0)
movementState = self.movementStateList.get(shutterId)
if movementState not in ('opening', 'closing'):
return state.position
secondsSinceLastCommand = time.monotonic() - state.lastCommandTime
if secondsSinceLastCommand <= 0:
return state.position
if movementState == 'opening':
duration = self.config.Shutters[shutterId]['durationUp']
if duration <= 0 or secondsSinceLastCommand >= duration:
return 100 # elapsed time already covers the full travel —
# report the target, not the stale pre-move
# position, even if settling hasn't fired yet
durationPercentage = secondsSinceLastCommand / duration * 100
estimated = state.position + durationPercentage if state.position > 0 else durationPercentage
return min(100, int(round(estimated)))
else:
duration = self.config.Shutters[shutterId]['durationDown']
if duration <= 0 or secondsSinceLastCommand >= duration:
return 0
durationPercentage = secondsSinceLastCommand / duration * 100
estimated = state.position - durationPercentage if state.position < 100 else 100 - durationPercentage
return max(0, int(round(estimated)))
def setPosition(self, shutterId, newPosition):
state = self.getShutterState(shutterId)
with self.shutterStateLock:
state.position = newPosition
# Every call site of setPosition() is already a "position settled"
# moment (end of waitAndSetFinalPosition, stop()'s immediate branch,
# the partial-move completions).
self.config.WriteValue(shutterId, str(newPosition), section="ShutterPositions")
for function in self.callback:
function(shutterId, newPosition)
def waitAndSetFinalPosition(self, shutterId, timeToWait, newPosition):
state = self.getShutterState(shutterId)
oldLastCommandTime = state.lastCommandTime
self.LogDebug("["+self.config.Shutters[shutterId]['name']+"] Waiting for operation to complete for " + str(timeToWait) + " seconds")
time.sleep(timeToWait)
# Only set new position if registerCommand has not been called in between
if state.lastCommandTime == oldLastCommandTime:
self.LogDebug("["+self.config.Shutters[shutterId]['name']+"] Set new final position: " + str(newPosition))
# A full/partial move that runs out its timer uninterrupted has
# genuinely stopped — fire this before setPosition() so, as
# elsewhere, the position callback's more precise open/closed
# inference has the last word. Without this, 'opening'/'closing'
# would stay stuck forever once a move completes naturally,
# since nothing else clears it for this (non-explicit-stop) path.
self._fireMovement(shutterId, 'stopped')
self.setPosition(shutterId, newPosition)
else:
self.LogDebug("["+self.config.Shutters[shutterId]['name']+"] Discard final position. Position is now: " + str(state.position))
def lower(self, shutterId):
self.LogInfo("["+self.config.Shutters[shutterId]['name']+"] Going down")
self.sendCommand(shutterId, self.buttonDown, self.config.SendRepeat)
self._simulateDown(shutterId)
# Position-model update for a "down" command, shared by the TX path
# (lower(), above) and the physical-remote path (recordExternalCommand).
def _simulateDown(self, shutterId):
state = self.getShutterState(shutterId, 100)
state.registerCommand('down')
self._fireMovement(shutterId, 'closing')
# wait and set final position only if not interrupted in between
timeToWait = state.position/100*self.config.Shutters[shutterId]['durationDown']
t = threading.Thread(target = self.waitAndSetFinalPosition, args = (shutterId, timeToWait, 0))
t.start()
def lowerPartial(self, shutterId, percentage):
state = self.getShutterState(shutterId, 100)
self.LogInfo("["+self.config.Shutters[shutterId]['name']+"] Going down")
self.sendCommand(shutterId, self.buttonDown, self.config.SendRepeat)
state.registerCommand('down')
self._fireMovement(shutterId, 'closing')
time.sleep((state.position-percentage)/100*self.config.Shutters[shutterId]['durationDown'])
self.LogInfo("["+self.config.Shutters[shutterId]['name']+"] Stop at partial position requested")
self.sendCommand(shutterId, self.buttonStop, self.config.SendRepeat)
self._fireMovement(shutterId, 'stopped')
self.setPosition(shutterId, percentage)
def rise(self, shutterId):
self.LogInfo("["+self.config.Shutters[shutterId]['name']+"] Going up")
self.sendCommand(shutterId, self.buttonUp, self.config.SendRepeat)
self._simulateUp(shutterId)
# Position-model update for an "up" command, shared by the TX path
# (rise(), above) and the physical-remote path (recordExternalCommand).
def _simulateUp(self, shutterId):
state = self.getShutterState(shutterId, 0)
state.registerCommand('up')
self._fireMovement(shutterId, 'opening')
# wait and set final position only if not interrupted in between
timeToWait = (100-state.position)/100*self.config.Shutters[shutterId]['durationUp']
t = threading.Thread(target = self.waitAndSetFinalPosition, args = (shutterId, timeToWait, 100))
t.start()
def risePartial(self, shutterId, percentage):
state = self.getShutterState(shutterId, 0)
self.LogInfo("["+self.config.Shutters[shutterId]['name']+"] Going up")
self.sendCommand(shutterId, self.buttonUp, self.config.SendRepeat)
state.registerCommand('up')
self._fireMovement(shutterId, 'opening')
time.sleep((percentage-state.position)/100*self.config.Shutters[shutterId]['durationUp'])
self.LogInfo("["+self.config.Shutters[shutterId]['name']+"] Stop at partial position requested")
self.sendCommand(shutterId, self.buttonStop, self.config.SendRepeat)
self._fireMovement(shutterId, 'stopped')
self.setPosition(shutterId, percentage)
def stop(self, shutterId):
self.LogInfo("["+self.config.Shutters[shutterId]['name']+"] Stopping")
self.sendCommand(shutterId, self.buttonStop, self.config.SendRepeat)
self._simulateStop(shutterId)
# Position-model update for a "stop"/"my" command, shared by the TX path
# (stop(), above) and the physical-remote path (recordExternalCommand).
# Inherits the full MY-button ping-pong the motors implement from the
# elapsed-time/fallback logic below — no special-casing needed for
# physical presses.
def _simulateStop(self, shutterId):
state = self.getShutterState(shutterId, 50)
self.LogDebug("["+shutterId+"] Previous position: " + str(state.position))
# Compute position based on time elapsed since last command & command direction
setupDurationDown = self.config.Shutters[shutterId]['durationDown']
setupDurationUp = self.config.Shutters[shutterId]['durationUp']
# Whether the shutter is genuinely moving right now comes from
# movementStateList (set by _simulateUp/_simulateDown, and cleared
# back to 'stopped' the instant ANY move settles — full, partial, or
# a previous MY-position correction), not from comparing elapsed
# time against the shutter's *full* 0-100 duration. That old
# elapsed-time heuristic misclassified a MY press shortly after a
# completed partial/MY move (which settles in less than the full
# duration) as "still mid-travel", computing a bogus interrupted
# position instead of correctly falling through to the MY-position
# logic below — the shutter was already stationary the whole time.
movementState = self.movementStateList.get(shutterId)
self.LogDebug("["+shutterId+"] Movement state: " + str(movementState))
fallback = False
if movementState == 'opening':
secondsSinceLastCommand = time.monotonic() - state.lastCommandTime
self.LogDebug("["+shutterId+"] Seconds since last command: " + str(round(secondsSinceLastCommand, 2)))
durationPercentage = int(round(secondsSinceLastCommand/setupDurationUp * 100)) if setupDurationUp > 0 else 100
self.LogDebug("["+shutterId+"] Up duration percentage: " + str(durationPercentage) + ", State position: "+ str(state.position))
if state.position > 0: # after rise from previous position
newPosition = min (100 , state.position + durationPercentage)
else: # after rise from fully closed
newPosition = durationPercentage
elif movementState == 'closing':
secondsSinceLastCommand = time.monotonic() - state.lastCommandTime
self.LogDebug("["+shutterId+"] Seconds since last command: " + str(round(secondsSinceLastCommand, 2)))
durationPercentage = int(round(secondsSinceLastCommand/setupDurationDown * 100)) if setupDurationDown > 0 else 100
self.LogDebug("["+shutterId+"] Down duration percentage: " + str(durationPercentage) + ", State position: "+ str(state.position))
if state.position < 100: # after lower from previous position
newPosition = max (0 , state.position - durationPercentage)
else: # after down from fully opened
newPosition = 100 - durationPercentage
else: # already stopped (fully, partially, or from a previous MY correction)
self.LogInfo("["+shutterId+"] Stop pressed while stationary.")
fallback = True
if fallback == True: # Let's assume it will end on the intermediate position ! If it exists !
intermediatePosition = self.config.Shutters[shutterId]['intermediatePosition']
if intermediatePosition is None or intermediatePosition == state.position:
self.LogInfo("["+shutterId+"] Stay stationary.")
newPosition = state.position
else:
self.LogInfo("["+shutterId+"] Motor expected to move to intermediate position "+str(intermediatePosition))
if state.position > intermediatePosition:
state.registerCommand('down')
self._fireMovement(shutterId, 'closing')
timeToWait = abs(state.position - intermediatePosition) / 100*self.config.Shutters[shutterId]['durationDown']
else:
state.registerCommand('up')
self._fireMovement(shutterId, 'opening')
timeToWait = abs(state.position - intermediatePosition) / 100*self.config.Shutters[shutterId]['durationUp']
# wait and set final intermediate position only if not interrupted in between
t = threading.Thread(target = self.waitAndSetFinalPosition, args = (shutterId, timeToWait, intermediatePosition))
t.start()
return # genuinely moving again — not a stop; the settled state
# reports via setPosition()'s existing position callback
# when the thread completes, same as rise()/lower().
# Fire the movement event before setPosition(): if the computed position
# lands exactly on 0/100, setPosition()'s existing position callback
# publishes the more precise open/closed state, and should have the
# last word on the retained MQTT topic, not this 'stopped' event.
self._fireMovement(shutterId, 'stopped')
# Save computed position
self.setPosition(shutterId, newPosition)
# Register command at the end to not impact the lastCommand timer
state.registerCommand(None)
# Push a set of buttons for a short or long press.
def pressButtons(self, shutterId, buttons, longPress):
self.sendCommand(shutterId, buttons, 35 if longPress else 1)
def program(self, shutterId):
self.sendCommand(shutterId, self.buttonProg, 1)
def registerCallBack(self, callbackFunction):
self.callback.append(callbackFunction)
# Register for transient movement-state notifications ('opening'/
# 'closing'/'stopped'), fired identically for the TX/software path and
# the physical-remote path — complements registerCallBack's position-
# based open/closed/stopped signal with the "movement in progress"
# state nothing else reports today.
def registerMovementCallBack(self, callbackFunction):
self.movementCallback.append(callbackFunction)
def _fireMovement(self, shutterId, movementState):
self.movementStateList[shutterId] = movementState
for function in self.movementCallback:
function(shutterId, movementState)
# Last movement state fired for a shutter ('opening'/'closing'/'stopped'),
# or None if it's never moved this run — lets a REST poller (getStatus)
# see the same signal MQTT gets pushed, without needing its own callback.
def getMovementState(self, shutterId):
return self.movementStateList.get(shutterId)
# Update the position model for a button press heard from a physical RTS
# remote — dispatches to the same _simulate* methods the TX path uses,
# with no RF transmission.
def recordExternalCommand(self, shutterId, button):
name = self.config.Shutters[shutterId]['name']
if button == self.buttonUp:
self.LogInfo("["+name+"] Physical remote: UP")
self._simulateUp(shutterId)
elif button == self.buttonDown:
self.LogInfo("["+name+"] Physical remote: DOWN")
self._simulateDown(shutterId)
elif button == self.buttonStop:
self.LogInfo("["+name+"] Physical remote: STOP/MY")
self._simulateStop(shutterId)
else:
self.LogWarn("["+name+"] Physical remote: unhandled button 0x%X" % button)
def sendCommand(self, shutterId, button, repetition): #Sending a frame
# Sending more than two repetitions after the original frame means a button kept pressed and moves the blind in steps
# to adjust the tilt. Sending the original frame and three repetitions is the smallest adjustment, sending the original
# frame and more repetitions moves the blinds up/down for a longer time.
# To activate the program mode (to register or de-register additional remotes) of your Somfy blinds, long press the
# prog button (at least thirteen times after the original frame to activate the registration.
self.LogDebug("sendCommand: Waiting for Lock")
self.lock.acquire()
try:
self.LogDebug("sendCommand: Lock acquired")
self.transmitting.set()
checksum = 0
teleco = int(shutterId, 16)
code = int(self.config.Shutters[shutterId]['code'])
# print (codecs.encode(shutterId, 'hex_codec'))
self.config.setCode(shutterId, code+1)
self.LogInfo ("Remote : " + "0x%0.2X" % teleco + ' (' + self.config.Shutters[shutterId]['name'] + ')')
self.LogInfo ("Button : " + "0x%0.2X" % button)
self.LogInfo ("Rolling code : " + str(code))
self.LogInfo ("")
self.frame[0] = 0xA7; # Encryption key. Doesn't matter much
self.frame[1] = button << 4 # Which button did you press? The 4 LSB will be the checksum
self.frame[2] = code >> 8 # Rolling code (big endian)
self.frame[3] = (code & 0xFF) # Rolling code
self.frame[4] = teleco >> 16 # Remote address
self.frame[5] = ((teleco >> 8) & 0xFF) # Remote address
self.frame[6] = (teleco & 0xFF) # Remote address
outstring = "Frame : "
for octet in self.frame:
outstring = outstring + "0x%0.2X" % octet + ' '
self.LogInfo (outstring)
for i in range(0, 7):
checksum = checksum ^ self.frame[i] ^ (self.frame[i] >> 4)
checksum &= 0b1111; # We keep the last 4 bits only
self.frame[1] |= checksum;
outstring = "With cks : "
for octet in self.frame:
outstring = outstring + "0x%0.2X" % octet + ' '
self.LogInfo (outstring)
for i in range(1, 7):
self.frame[i] ^= self.frame[i-1];
outstring = "Obfuscated :"
for octet in self.frame:
outstring = outstring + "0x%0.2X" % octet + ' '
self.LogInfo (outstring)
self.rf_transmitter.transmit(self.frame, repetition)
finally:
self.transmitting.clear()
self.lock.release()
self.LogDebug("sendCommand: Lock released")
class operateShutters(MyLog):
def __init__(self, args = None):
super(operateShutters, self).__init__()
self.ProgramName = "operate Somfy Shutters"
self.Version = "Unknown"
self.log = None
self.IsStopping = False
self.ProgramComplete = False
if args.ConfigFile is None:
self.ConfigFile = "/etc/operateShutters.conf"
else:
self.ConfigFile = args.ConfigFile
self.console = SetupLogger("shutters_console", log_file = "", stream = True)
if not WINDOWS:
if os.geteuid() != 0:
self.LogConsole("You need to have root privileges to run this script.\nPlease try again, this time using 'sudo'.")
sys.exit(1)
if not os.path.isfile(self.ConfigFile):
self.LogConsole("Creating new config file : " + self.ConfigFile)
defaultConfigFile = os.path.dirname(os.path.realpath(__file__))+'/defaultConfig.conf'
print(defaultConfigFile);
if not os.path.isfile(defaultConfigFile):
self.LogConsole("Failure to create new config file: "+defaultConfigFile)
sys.exit(1)
else:
copyfile(defaultConfigFile, self.ConfigFile)
# read config file
self.config = MyConfig(filename = self.ConfigFile, log = self.console)
result = self.config.LoadConfig();
if not result:
self.LogConsole("Failure to load configuration parameters")
sys.exit(1)
# log errors in this module to a file
logLocation = self.config.LogLocation
if WINDOWS and not os.path.isdir(logLocation):
logLocation = "./"
self.log = SetupLogger("shutters", logLocation + "operateShutters.log")
self.config.log = self.log
if not WINDOWS and self.IsLoaded():
self.LogWarn("operateShutters.py is already loaded.")
sys.exit(1)
if not WINDOWS and not self.startGPIO():
self.LogConsole("Not able to start GPIO")
sys.exit(1)
self.shutter = Shutter(log = self.log, config = self.config)
# The TX GPIO comes up as a floating input, which makes the 433 MHz
# module transmit continuously until the first command is sent (#154).
if not WINDOWS:
try:
self.shutter.rf_transmitter.set_idle_low()
except Exception as e:
self.LogError("Could not drive the TX GPIO low at startup: {}".format(e))
self.schedule = Schedule(log = self.log, config = self.config)
self.scheduler = None
self.webServer = None
self.receiver = None
if (args.echo == True):
self.alexa = Alexa(kwargs={'log':self.log, 'shutter': self.shutter, 'config': self.config})
if (args.mqtt == True):
from mqtt import MQTT
self.mqtt = MQTT(kwargs={'log':self.log, 'shutter': self.shutter, 'config': self.config})
# Enabled when RXGPIO is present in [General] — no new CLI flag.
if not WINDOWS and self.config.RXGPIO is not None:
from receiver import Receiver
self.receiver = Receiver(kwargs={'log':self.log, 'shutter': self.shutter, 'config': self.config, 'console': self.console})
self.ProcessCommand(args);
#------------------------ operateShutters::IsLoaded -----------------------------
#return true if program is already loaded
def IsLoaded(self):
file_path = '/var/lock/'+os.path.basename(__file__)
global file_handle
try:
file_handle= open(file_path, 'w')
fcntl.lockf(file_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
return False
except IOError:
return True
#--------------------- operateShutters::startGPIO ---------------------------------
def startGPIO(self):
if IS_PI5:
global LGPIO_CHIP
# lgpio accesses /dev/gpiochip* directly — no daemon needed
# Try gpiochip4 first (older Pi 5 kernels), then gpiochip0 (newer kernels)
for chip in [4, 0]:
try:
h = lgpio.gpiochip_open(chip)
lgpio.gpiochip_close(h)
LGPIO_CHIP = chip
self.LogInfo("lgpio: gpiochip{} opened successfully (Pi 5)".format(chip))
return True
except Exception as e:
self.LogInfo("lgpio: could not open gpiochip{}: {}".format(chip, e))
self.LogError("lgpio: no usable gpiochip found")
return False
# pigpio path for Pi 1/2/3/4
# Deliberately not passing -m (disable alerts): -m silently prevents
# pi.callback() from ever delivering edge notifications, which the
# receiver (Receiver, when config.RXGPIO is set) needs.
# No sudo: __init__ already refused to run unless os.geteuid() == 0,
# so this process is already root in every deployment (standalone,
# systemd, or a container) — sudo here would just add a dependency
# this code doesn't need.
if sys.version_info[0] < 3:
import commands
status, process = commands.getstatusoutput('pidof pigpiod')
if status: # it wasn't running, so start it
self.LogInfo ("pigpiod was not running")
commands.getstatusoutput('pigpiod -l') # try to start it
time.sleep(0.5)
# check it again
status, process = commands.getstatusoutput('pidof pigpiod')
else:
import subprocess
status, process = subprocess.getstatusoutput('pidof pigpiod')
if status: # it wasn't running, so start it
self.LogInfo ("pigpiod was not running")
subprocess.getstatusoutput('pigpiod -l') # try to start it
time.sleep(0.5)
# check it again
status, process = subprocess.getstatusoutput('pidof pigpiod')
if not status: # if it was started successfully (or was already running)...
pigpiod_process = process
self.LogInfo ("pigpiod is running, process ID is {} ".format(pigpiod_process))
self.LogConsole("pigpiod is running, process ID is {} ".format(pigpiod_process))
try:
pi = pigpio.pi() # local GPIO only
if not pi.connected:
self.LogError("pigpio connection could not be established. Check logs to get more details.")
self.LogConsole("pigpio connection could not be established.")
return False
else:
self.LogInfo("pigpio's pi instantiated.")
self.LogConsole("pigpio's pi instantiated.")
except Exception as e:
start_pigpiod_exception = str(e)
self.LogError("problem instantiating pi: {}".format(start_pigpiod_exception))
self.LogConsole("problem instantiating pi: {}".format(start_pigpiod_exception))
else:
self.LogError("start pigpiod was unsuccessful.")
self.LogConsole("start pigpiod was unsuccessful (pidof pigpiod status={}).".format(status))
return False
return True
#--------------------- operateShutters::ProcessCommand -----------------------------------------------
def ProcessCommand(self, args):
if ((args.long == True) and not (args.press)):
print("ERROR: The -long option can only be specified with the -press option.\n")
parser.print_help()
elif ((args.shutterName != "") and (args.down == True)):
self.shutter.lower(self.config.ShuttersByName[args.shutterName])
elif ((args.shutterName != "") and (args.up == True)):
self.shutter.rise(self.config.ShuttersByName[args.shutterName])
elif ((args.shutterName != "") and (args.stop == True)):
self.shutter.stop(self.config.ShuttersByName[args.shutterName])
elif ((args.shutterName != "") and (args.program == True)):
self.shutter.program(self.config.ShuttersByName[args.shutterName])
elif ((args.shutterName != "") and (args.demo == True)):
self.LogInfo ("lowering shutter")
self.shutter.lowerPartial(self.config.ShuttersByName[args.shutterName], 0)
time.sleep(7)
self.LogInfo ("raising shutter")
self.shutter.risePartial(self.config.ShuttersByName[args.shutterName], 100)
elif ((args.shutterName != "") and (args.duskdawn is not None)):
self.schedule.addRepeatEventBySunrise([self.config.ShuttersByName[args.shutterName]], 'up', args.duskdawn[1], ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'])
self.schedule.addRepeatEventBySunset([self.config.ShuttersByName[args.shutterName]], 'down', args.duskdawn[0], ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'])
self.scheduler = Scheduler(kwargs={'log':self.log, 'schedule':self.schedule, 'shutter': self.shutter, 'config': self.config})
self.scheduler.daemon = True
self.scheduler.start()
if (args.echo == True):
self.alexa.daemon = True
self.alexa.start()
if (args.mqtt == True):
self.mqtt.daemon = True
self.mqtt.start()
if self.receiver is not None:
self.receiver.daemon = True
self.receiver.start()
self.scheduler.join()
elif ((args.shutterName != "") and (args.press)):
buttons = 0
btnMap = {
'up': self.shutter.buttonUp,
'down': self.shutter.buttonDown,
'stop': self.shutter.buttonStop,
'my': self.shutter.buttonStop,
'program': self.shutter.buttonProg
}
for btn in args.press:
buttons |= btnMap[btn]
self.shutter.pressButtons(self.config.ShuttersByName[args.shutterName], buttons, args.long)
elif (args.auto == True):
self.schedule.loadScheduleFromConfig()
self.scheduler = Scheduler(kwargs={'log':self.log, 'schedule':self.schedule, 'shutter': self.shutter, 'config': self.config})
self.scheduler.daemon = True
self.scheduler.start()
if (args.echo == True):
self.alexa.daemon = True
self.alexa.start()
if (args.mqtt == True):
self.mqtt.daemon = True
self.mqtt.start()
if self.receiver is not None:
self.receiver.daemon = True
self.receiver.start()
self.webServer = FlaskAppWrapper(name='WebServer', static_url_path=os.path.dirname(os.path.realpath(__file__))+'/html', log = self.log, shutter = self.shutter, schedule = self.schedule, config = self.config, receiver = self.receiver)
self.webServer.run()
else:
parser.print_help()
if (args.echo == True):
self.alexa.daemon = True
self.alexa.start()
if (args.mqtt == True):
self.mqtt.daemon = True
self.mqtt.start()
if self.receiver is not None:
self.receiver.daemon = True
self.receiver.start()
if (args.echo == True):
self.alexa.join()
if (args.mqtt == True):
self.mqtt.join()
if self.receiver is not None:
self.receiver.join()
self.LogInfo ("Process Command Completed....")
self.Close();
#---------------------operateShutters::Close----------------------------------------
def Close(self, signum = None, frame = None):
# we dont really care about the errors that may be generated on shutdown
try:
self.IsStopping = True
except Exception as e1:
self.LogErrorLine("Error Closing Monitor: " + str(e1))
self.LogError("operateShutters Shutdown")
try:
self.ProgramComplete = True
if self.scheduler is not None:
self.LogError("Stopping Scheduler. This can take up to 1 second...")
self.scheduler.shutdown_flag.set()
self.scheduler.join()
self.LogError("Scheduler stopped. Now exiting.")
if self.alexa is not None:
self.LogError("Stopping Alexa Listener. This can take up to 1 second...")
self.alexa.shutdown_flag.set()
self.alexa.join()
self.LogError("Alexa Listener stopped. Now exiting.")
if self.mqtt is not None:
self.LogError("Stopping MQTT Listener. This can take up to 1 second...")
self.mqtt.shutdown_flag.set()
self.mqtt.join()
self.LogError("MQTT Listener stopped. Now exiting.")
if self.receiver is not None:
self.LogError("Stopping RTS Receiver. This can take up to 1 second...")
self.receiver.shutdown_flag.set()
self.receiver.join()
self.LogError("RTS Receiver stopped. Now exiting.")
if self.webServer is not None:
self.LogError("Stopping WebServer. This can take up to 1 second...")
self.webServer.shutdown_server()
self.LogError("WebServer stopped. Now exiting.")
sys.exit(0)
except:
pass
#------------------- Command-line interface for monitor ------------------------
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='operate Somfy Shutters.')
parser.add_argument('shutterName', nargs='?', help='Name of the Shutter')
parser.add_argument('-config', '-c', dest='ConfigFile', default=os.getcwd()+'/operateShutters.conf', help='Name of the Config File (incl full Path)')
parser.add_argument('-up', '-u', help='Raise the Shutter', action='store_true')
parser.add_argument('-down', '-d', help='lower the Shutter', action='store_true')
parser.add_argument('-stop', '-s', help='stop the Shutter', action='store_true')
parser.add_argument('-program', '-p', help='program a new Shutter', action='store_true')
parser.add_argument('-press', help='Simulate a press of the specified remote buttons (\'up\', \'down\', \'stop\'/\'my\', and \'program\'). You can specify multiple buttons to activate setup operations. This does not update the known state of the blinds, so should not be used for ordinary raise and lower operations.', metavar='BTN', nargs='+', type=str)
parser.add_argument('-long', help='When used with the -press option, simulates a long press, instead of a short press.', action='store_true')
parser.add_argument('-demo', help='lower the Shutter, Stop after 7 second, then raise the Shutter', action='store_true')
parser.add_argument('-duskdawn', '-dd', type=int, nargs=2, help='Automatically lower the shutter at sunset and rise the shutter at sunrise, provide the evening delay and morning delay in minutes each')
parser.add_argument('-auto', '-a', help='Run schedule based on config. Also will start up the web-server which can be used to setup the schedule. Try: https://'+socket.gethostname(), action='store_true')
parser.add_argument('-echo', '-e', help='Enable Amazon Alexa (Echo) integration', action='store_true')
parser.add_argument('-mqtt', '-m', help='Enable MQTT integration', action='store_true')
args = parser.parse_args()
#Start things up
MyShutter = operateShutters(args = args)
try:
while not MyShutter.ProgramComplete:
time.sleep(0.01)
sys.exit(0)
except:
sys.exit(1)