From 5fb56b72511d30face7a9a42d2064f9b94ce065d Mon Sep 17 00:00:00 2001 From: Idan Freiberg Date: Fri, 5 Jun 2026 06:14:49 +0300 Subject: [PATCH 01/13] feat: add cc1101 rf backend --- .gitignore | 3 +- README.md | 35 ++++ config.py | 33 +++- defaultConfig.conf | 13 +- .../plans/2026-06-05-e07-m1101d-sma-cc1101.md | 47 ++++++ operateShutters.py | 151 +++++++++++------- requirements.txt | 1 + tests/__init__.py | 1 + tests/test_rf_backend.py | 128 +++++++++++++++ tests/test_rf_config.py | 85 ++++++++++ 10 files changed, 434 insertions(+), 63 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md create mode 100644 tests/__init__.py create mode 100644 tests/test_rf_backend.py create mode 100644 tests/test_rf_config.py diff --git a/.gitignore b/.gitignore index 39672af..238d4ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ /.venv /__pycache__ +__pycache__/ # User config (contains GPS coordinates, shutter names, MQTT credentials) operateShutters.conf # Log files (contain personal data and occupancy patterns) *.log -*.log.* \ No newline at end of file +*.log.* diff --git a/README.md b/README.md index 5a99586..dfc0397 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,34 @@ Note that I used GPIO 4 but you can change the value of __TXGPIO__ to whatever y OK. now this all should look like this. Note that some of the pictures are a bit confusing with regards to which GPIO a cable connects to. It's easier to see on the above diagram. But if you struggle, maybe the [Wiring Diagram](documentation/Wiring%20Diagram.txt) helps. +### E07-M1101D-SMA / CC1101 option + +Pi-Somfy can also drive an E07-M1101D-SMA CC1101 module. This module is controlled over SPI and uses the CC1101 asynchronous transmit mode, with the Somfy RTS waveform driven into GDO0. + +The E07-M1101D-SMA must be powered from 3.3V. Do not connect VCC or any logic pin to 5V. + +| E07-M1101D-SMA pin | Raspberry Pi 4 physical pin | Raspberry Pi signal | +| --- | ---: | --- | +| 1 GND | 6 | GND | +| 2 VCC | 1 or 17 | 3.3V | +| 3 GDO0 | 7 | GPIO4 / TXGPIO | +| 4 CSN | 24 | SPI0 CE0 / GPIO8 | +| 5 SCK | 23 | SPI0 SCLK / GPIO11 | +| 6 MOSI | 19 | SPI0 MOSI / GPIO10 | +| 7 MISO/GDO1 | 21 | SPI0 MISO / GPIO9 | +| 8 GDO2 | 22 | GPIO25, optional | + +Enable SPI on the Pi, then set the RF backend in `operateShutters.conf`: + +```ini +TXGPIO = 4 +RFBackend = cc1101 +CC1101Frequency = 433.42 +CC1101SPIBus = 0 +CC1101SPIDevice = 0 +CC1101OutputPower = 0xC6 +``` + ![Full Picture](documentation/Full%20Assembly.jpg)
![Pi Connection](documentation/Connection.jpg)
@@ -79,6 +107,13 @@ Next, we need to install the PIGPIO libraries, to do so, type: sudo apt-get install pigpio ``` +For the E07-M1101D-SMA / CC1101 backend, also enable SPI and install the spidev package: + +```sh +sudo raspi-config +sudo apt-get install python3-spidev +``` + Next install the required Python Libraries: ```sh diff --git a/config.py b/config.py index 8973050..0eacb6f 100644 --- a/config.py +++ b/config.py @@ -126,6 +126,11 @@ def __init__(self, filename = None, section = None, log = None): self.UseHttps = False self.HTTPPort = 80 self.HTTPSPort = 443 + self.RFBackend = "gpio" + self.CC1101Frequency = 433.42 + self.CC1101SPIBus = 0 + self.CC1101SPIDevice = 0 + self.CC1101OutputPower = 0xC6 self.RTS_Address = "0x279620" self.MQTT_ClientID = "somfy-mqtt-bridge" self.Shutters = {} @@ -150,7 +155,23 @@ def __init__(self, filename = None, section = None, log = None): # -------------------- MyConfig::LoadConfig----------------------------------- def LoadConfig(self): - parameters = {'LogLocation': str, 'Latitude': float, 'Longitude': float, 'SendRepeat': int, 'UseHttps': bool, 'HTTPPort': int, 'HTTPSPort': int, 'TXGPIO': int, 'RTS_Address': str, "Password": str} + parameters = { + 'LogLocation': str, + 'Latitude': float, + 'Longitude': float, + 'SendRepeat': int, + 'UseHttps': bool, + 'HTTPPort': int, + 'HTTPSPort': int, + 'TXGPIO': int, + 'RFBackend': str, + 'CC1101Frequency': float, + 'CC1101SPIBus': int, + 'CC1101SPIDevice': int, + 'CC1101OutputPower': int, + 'RTS_Address': str, + "Password": str + } for key, type in parameters.items(): try: @@ -160,6 +181,8 @@ def LoadConfig(self): self.LogErrorLine("Missing config file or config file entries in Section General for key "+key+": " + str(e1)) return False + self.RFBackend = self.RFBackend.strip().lower() + parameters = {'MQTT_Server': str, 'MQTT_Port': int, 'MQTT_User': str, 'MQTT_Password': str, 'MQTT_ClientID': str, 'EnableDiscovery': bool} for key, type in parameters.items(): @@ -262,10 +285,14 @@ def ReadValue(self, Entry, return_type = str, default = None, section = None, No elif return_type == float: return self.config.getfloat(sect, Entry) elif return_type == int: - if self.config.get(sect, Entry) == 'None': + value = self.config.get(sect, Entry) + if value == 'None': return None else: - return self.config.getint(sect, Entry) + try: + return int(value, 0) + except ValueError: + return self.config.getint(sect, Entry) else: self.LogErrorLine("Error in MyConfig:ReadValue: invalid type:" + str(return_type)) return default diff --git a/defaultConfig.conf b/defaultConfig.conf index bb0599b..51bde17 100644 --- a/defaultConfig.conf +++ b/defaultConfig.conf @@ -25,6 +25,18 @@ SendRepeat = 2 # emitter is connected to. The default value is 4 TXGPIO = 4 +# (Optional) RF backend used for transmission. Keep "gpio" for the original +# 3-pin 433.42 MHz transmitter. Use "cc1101" for modules such as the +# E07-M1101D-SMA, with GDO0 wired to TXGPIO. +RFBackend = gpio + +# (Optional) CC1101 settings used only when RFBackend = cc1101. +# The E07-M1101D-SMA is a 3.3V SPI CC1101 module. Do not power it from 5V. +CC1101Frequency = 433.42 +CC1101SPIBus = 0 +CC1101SPIDevice = 0 +CC1101OutputPower = 0xC6 + # This parameter, if true will enable the use of HTTPS # (secure HTTP) in the Flask web app or user name and password # authentication, depending on the options below. This option is only @@ -131,4 +143,3 @@ EnableDiscovery = true # [Scheduler] - diff --git a/docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md b/docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md new file mode 100644 index 0000000..1647bea --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md @@ -0,0 +1,47 @@ +# E07-M1101D-SMA CC1101 Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a selectable CC1101/E07-M1101D-SMA RF backend while preserving the existing one-pin GPIO transmitter behavior. + +**Architecture:** Keep Somfy RTS frame generation unchanged. Split transmission so `sendCommand()` builds the frame once, then dispatches to either the existing GPIO waveform path or a CC1101 wrapper that configures the radio over SPI and reuses the same waveform on GDO0. + +**Tech Stack:** Python 3, `unittest`, `pigpio`/`lgpio`, optional `cc1101` plus `spidev` for the E07 module. + +--- + +### Task 1: Backend Config Tests + +**Files:** +- Create: `tests/test_rf_config.py` +- Modify: `config.py` + +- [ ] Write a `unittest` test that loads a temporary config containing `RFBackend = cc1101`, `TXGPIO = 4`, `CC1101Frequency = 433.42`, `CC1101SPIBus = 0`, `CC1101SPIDevice = 0`, and `CC1101OutputPower = 0xC6`, then asserts the parsed attributes. +- [ ] Run `python3 -m unittest tests.test_rf_config -v` and verify it fails because these config fields are not parsed yet. +- [ ] Add default values and parser entries in `MyConfig.LoadConfig`. +- [ ] Re-run the test and verify it passes. + +### Task 2: Backend Dispatch Tests + +**Files:** +- Create: `tests/test_rf_backend.py` +- Modify: `operateShutters.py` + +- [ ] Write a test that instantiates `Shutter` with `RFBackend = gpio` and patches `_sendWave_gpio` to assert it is called. +- [ ] Write a test that instantiates `Shutter` with `RFBackend = cc1101`, injects a fake CC1101 module, patches `_sendWave_gpio`, and asserts the radio is configured and `asynchronous_transmission()` wraps the waveform call. +- [ ] Run `python3 -m unittest tests.test_rf_backend -v` and verify it fails because backend dispatch does not exist yet. +- [ ] Extract the existing pigpio transmit block into `_sendWave_pigpio`, keep the existing lgpio path, add `_sendWave_gpio`, and add `_sendWave_cc1101`. +- [ ] Re-run backend tests and verify they pass. + +### Task 3: Dependencies And Docs + +**Files:** +- Modify: `requirements.txt` +- Modify: `defaultConfig.conf` +- Modify: `README.md` + +- [ ] Add `cc1101` and document that `python3-spidev` may be needed on Raspberry Pi OS. +- [ ] Add commented CC1101 config keys to `defaultConfig.conf`. +- [ ] Add an E07-M1101D-SMA wiring table and config snippet to `README.md`. +- [ ] Run the full test suite with `python3 -m unittest discover -v`. +- [ ] Run `python3 -m py_compile operateShutters.py config.py`. diff --git a/operateShutters.py b/operateShutters.py index 663fd2e..54ced1e 100755 --- a/operateShutters.py +++ b/operateShutters.py @@ -112,6 +112,7 @@ class Shutter(MyLog): buttonStop = 0x1 buttonDown = 0x4 buttonProg = 0x8 + CC1101_SYMBOL_RATE_BAUD = 1562.5 class ShutterState: # Definition of one shutter state position = None # as percentage: 0 = closed (down), 100 = open (up) @@ -134,10 +135,11 @@ def __init__(self, log = None, config = None): if config is not None: self.config = config - if self.config.TXGPIO is not None: + 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", "gpio").strip().lower() self.frame = bytearray(7) self.callback = [] self.shutterStateList = {} @@ -297,7 +299,7 @@ def program(self, shutterId): def registerCallBack(self, callbackFunction): self.callback.append(callbackFunction) - def sendCommand(self, shutterId, button, repetition): #Sending a frame + def sendCommand(self, shutterId, button, repetition, cc1101_module=None): #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. @@ -353,66 +355,99 @@ def sendCommand(self, shutterId, button, repetition): #Sending a frame outstring = outstring + "0x%0.2X" % octet + ' ' self.LogInfo (outstring) - if IS_PI5: - self._sendWave_lgpio(repetition) - else: - #This is where all the awesomeness is happening. You're telling the daemon what you wanna send - pi = pigpio.pi() # connect to Pi - - if not pi.connected: - exit() - - pi.wave_add_new() - pi.set_mode(self.TXGPIO, pigpio.OUTPUT) - - wf=[] - wf.append(pigpio.pulse(1<> (7 - (i%8))) & 1): - wf.append(pigpio.pulse(0, 1<> (7 - (i%8))) & 1): - wf.append(pigpio.pulse(0, 1<> (7 - (i%8))) & 1): + wf.append(pigpio.pulse(0, 1<> (7 - (i%8))) & 1): + wf.append(pigpio.pulse(0, 1< Date: Fri, 5 Jun 2026 06:14:49 +0300 Subject: [PATCH 02/13] refactor: isolate cc1101 transmitter backend --- cc1101_backend.py | 68 +++++++++++++++++++++++++ config.py | 9 ++-- operateShutters.py | 25 +++------- tests/test_cc1101_backend.py | 96 ++++++++++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 22 deletions(-) create mode 100644 cc1101_backend.py create mode 100644 tests/test_cc1101_backend.py diff --git a/cc1101_backend.py b/cc1101_backend.py new file mode 100644 index 0000000..3cac19a --- /dev/null +++ b/cc1101_backend.py @@ -0,0 +1,68 @@ +#!/usr/bin/python3 + +class CC1101Config: + DEFAULT_FREQUENCY_MHZ = 433.42 + DEFAULT_SPI_BUS = 0 + DEFAULT_SPI_DEVICE = 0 + DEFAULT_OUTPUT_POWER = 0xC6 + SYMBOL_RATE_BAUD = 1562.5 + + def __init__( + self, + frequency_mhz=DEFAULT_FREQUENCY_MHZ, + spi_bus=DEFAULT_SPI_BUS, + spi_device=DEFAULT_SPI_DEVICE, + output_power=DEFAULT_OUTPUT_POWER, + ): + self.frequency_mhz = float(frequency_mhz) + self.spi_bus = int(spi_bus) + self.spi_device = int(spi_device) + self.output_power = int(output_power) + self.symbol_rate_baud = self.SYMBOL_RATE_BAUD + + @classmethod + def from_app_config(cls, config): + return cls( + frequency_mhz=getattr(config, "CC1101Frequency", cls.DEFAULT_FREQUENCY_MHZ), + spi_bus=getattr(config, "CC1101SPIBus", cls.DEFAULT_SPI_BUS), + spi_device=getattr(config, "CC1101SPIDevice", cls.DEFAULT_SPI_DEVICE), + output_power=getattr(config, "CC1101OutputPower", cls.DEFAULT_OUTPUT_POWER), + ) + + @property + def frequency_hz(self): + return self.frequency_mhz * 1000000 + + @property + def output_power_table(self): + return (0, self.output_power) + + +class CC1101Transmitter: + def __init__(self, config, cc1101_module=None): + self.config = config + self.cc1101_module = cc1101_module + + def _load_cc1101_module(self): + if self.cc1101_module is not None: + return self.cc1101_module + try: + import cc1101 + return cc1101 + except ImportError as e: + raise RuntimeError( + "RFBackend=cc1101 requires the cc1101 Python package: " + str(e) + ) + + def transmit(self, waveform_sender, repetition): + cc1101_module = self._load_cc1101_module() + with cc1101_module.CC1101( + spi_bus=self.config.spi_bus, + spi_chip_select=self.config.spi_device, + lock_spi_device=True, + ) as radio: + radio.set_base_frequency_hertz(self.config.frequency_hz) + radio.set_symbol_rate_baud(self.config.symbol_rate_baud) + radio.set_output_power(self.config.output_power_table) + with radio.asynchronous_transmission(): + waveform_sender(repetition) diff --git a/config.py b/config.py index 0eacb6f..c12ddaa 100644 --- a/config.py +++ b/config.py @@ -5,6 +5,7 @@ import logging, logging.handlers import threading import re +from cc1101_backend import CC1101Config try: from ConfigParser import RawConfigParser @@ -127,10 +128,10 @@ def __init__(self, filename = None, section = None, log = None): self.HTTPPort = 80 self.HTTPSPort = 443 self.RFBackend = "gpio" - self.CC1101Frequency = 433.42 - self.CC1101SPIBus = 0 - self.CC1101SPIDevice = 0 - self.CC1101OutputPower = 0xC6 + self.CC1101Frequency = CC1101Config.DEFAULT_FREQUENCY_MHZ + self.CC1101SPIBus = CC1101Config.DEFAULT_SPI_BUS + self.CC1101SPIDevice = CC1101Config.DEFAULT_SPI_DEVICE + self.CC1101OutputPower = CC1101Config.DEFAULT_OUTPUT_POWER self.RTS_Address = "0x279620" self.MQTT_ClientID = "somfy-mqtt-bridge" self.Shutters = {} diff --git a/operateShutters.py b/operateShutters.py index 54ced1e..839c2ce 100755 --- a/operateShutters.py +++ b/operateShutters.py @@ -101,6 +101,8 @@ def __init__(self, *a, **kw): pass from webserver import FlaskAppWrapper from alexa import Alexa from shutil import copyfile + from cc1101_backend import CC1101Config + from cc1101_backend import CC1101Transmitter 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)) @@ -112,7 +114,6 @@ class Shutter(MyLog): buttonStop = 0x1 buttonDown = 0x4 buttonProg = 0x8 - CC1101_SYMBOL_RATE_BAUD = 1562.5 class ShutterState: # Definition of one shutter state position = None # as percentage: 0 = closed (down), 100 = open (up) @@ -375,23 +376,11 @@ def _sendWave_gpio(self, repetition): self._sendWave_pigpio(repetition) def _sendWave_cc1101(self, repetition, cc1101_module=None): - if cc1101_module is None: - try: - import cc1101 as cc1101_module - except ImportError as e: - self.FatalError("RFBackend=cc1101 requires the cc1101 Python package: " + str(e)) - - frequency_hz = float(getattr(self.config, "CC1101Frequency", 433.42)) * 1000000 - spi_bus = int(getattr(self.config, "CC1101SPIBus", 0)) - spi_device = int(getattr(self.config, "CC1101SPIDevice", 0)) - output_power = int(getattr(self.config, "CC1101OutputPower", 0xC6)) - - with cc1101_module.CC1101(spi_bus=spi_bus, spi_chip_select=spi_device, lock_spi_device=True) as radio: - radio.set_base_frequency_hertz(frequency_hz) - radio.set_symbol_rate_baud(self.CC1101_SYMBOL_RATE_BAUD) - radio.set_output_power((0, output_power)) - with radio.asynchronous_transmission(): - self._sendWave_gpio(repetition) + try: + transmitter = CC1101Transmitter(CC1101Config.from_app_config(self.config), cc1101_module=cc1101_module) + transmitter.transmit(self._sendWave_gpio, repetition) + except RuntimeError as e: + self.FatalError(str(e)) def _sendWave_pigpio(self, repetition): #This is where all the awesomeness is happening. You're telling the daemon what you wanna send diff --git a/tests/test_cc1101_backend.py b/tests/test_cc1101_backend.py new file mode 100644 index 0000000..ab5e728 --- /dev/null +++ b/tests/test_cc1101_backend.py @@ -0,0 +1,96 @@ +import contextlib +import types +import unittest + +from cc1101_backend import CC1101Config, CC1101Transmitter + + +class FakeRadio: + instances = [] + + def __init__(self, spi_bus, spi_chip_select, lock_spi_device): + self.spi_bus = spi_bus + self.spi_chip_select = spi_chip_select + self.lock_spi_device = lock_spi_device + self.calls = [] + FakeRadio.instances.append(self) + + def __enter__(self): + self.calls.append(("enter",)) + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.calls.append(("exit", exc_type)) + return False + + def set_base_frequency_hertz(self, frequency_hertz): + self.calls.append(("frequency", frequency_hertz)) + + def set_symbol_rate_baud(self, symbol_rate): + self.calls.append(("symbol_rate", symbol_rate)) + + def set_output_power(self, output_power): + self.calls.append(("output_power", tuple(output_power))) + + @contextlib.contextmanager + def asynchronous_transmission(self): + self.calls.append(("async_enter",)) + yield "GDO0" + self.calls.append(("async_exit",)) + + +class CC1101BackendTest(unittest.TestCase): + def setUp(self): + FakeRadio.instances = [] + + def test_config_is_derived_from_app_config(self): + app_config = types.SimpleNamespace( + CC1101Frequency=433.42, + CC1101SPIBus=0, + CC1101SPIDevice=1, + CC1101OutputPower=0xC6, + ) + + config = CC1101Config.from_app_config(app_config) + + self.assertEqual(433.42e6, config.frequency_hz) + self.assertEqual(0, config.spi_bus) + self.assertEqual(1, config.spi_device) + self.assertEqual(0xC6, config.output_power) + self.assertEqual(1562.5, config.symbol_rate_baud) + + def test_transmitter_configures_radio_and_calls_waveform_callback(self): + fake_cc1101 = types.SimpleNamespace(CC1101=FakeRadio) + config = CC1101Config( + frequency_mhz=433.42, + spi_bus=0, + spi_device=0, + output_power=0xC6, + ) + calls = [] + + transmitter = CC1101Transmitter(config, cc1101_module=fake_cc1101) + transmitter.transmit(lambda repetition: calls.append(("waveform", repetition)), 3) + + self.assertEqual([("waveform", 3)], calls) + self.assertEqual(1, len(FakeRadio.instances)) + radio = FakeRadio.instances[0] + self.assertEqual(0, radio.spi_bus) + self.assertEqual(0, radio.spi_chip_select) + self.assertTrue(radio.lock_spi_device) + self.assertEqual( + [ + ("enter",), + ("frequency", 433.42e6), + ("symbol_rate", 1562.5), + ("output_power", (0, 0xC6)), + ("async_enter",), + ("async_exit",), + ("exit", None), + ], + radio.calls, + ) + + +if __name__ == "__main__": + unittest.main() From 42406ccc6e7686c20b1e0b3836255778420461e5 Mon Sep 17 00:00:00 2001 From: Idan Freiberg Date: Fri, 5 Jun 2026 06:30:21 +0300 Subject: [PATCH 03/13] refactor: keep cc1101 config inside backend --- cc1101_backend.py | 47 ++++++++++++++++++++++++++++-------- config.py | 9 ------- operateShutters.py | 19 ++++++++++----- tests/test_cc1101_backend.py | 37 ++++++++++++++++++++++++++++ tests/test_rf_backend.py | 20 +++++++++++++-- tests/test_rf_config.py | 16 ++++++------ 6 files changed, 113 insertions(+), 35 deletions(-) diff --git a/cc1101_backend.py b/cc1101_backend.py index 3cac19a..4ec2498 100644 --- a/cc1101_backend.py +++ b/cc1101_backend.py @@ -22,6 +22,33 @@ def __init__( @classmethod def from_app_config(cls, config): + if hasattr(config, "ReadValue"): + return cls( + frequency_mhz=config.ReadValue( + "CC1101Frequency", + return_type=float, + default=cls.DEFAULT_FREQUENCY_MHZ, + section="General", + ), + spi_bus=config.ReadValue( + "CC1101SPIBus", + return_type=int, + default=cls.DEFAULT_SPI_BUS, + section="General", + ), + spi_device=config.ReadValue( + "CC1101SPIDevice", + return_type=int, + default=cls.DEFAULT_SPI_DEVICE, + section="General", + ), + output_power=config.ReadValue( + "CC1101OutputPower", + return_type=int, + default=cls.DEFAULT_OUTPUT_POWER, + section="General", + ), + ) return cls( frequency_mhz=getattr(config, "CC1101Frequency", cls.DEFAULT_FREQUENCY_MHZ), spi_bus=getattr(config, "CC1101SPIBus", cls.DEFAULT_SPI_BUS), @@ -41,11 +68,16 @@ def output_power_table(self): class CC1101Transmitter: def __init__(self, config, cc1101_module=None): self.config = config - self.cc1101_module = cc1101_module + cc1101_module = self._load_cc1101_module(cc1101_module) + self.radio = cc1101_module.CC1101( + spi_bus=self.config.spi_bus, + spi_chip_select=self.config.spi_device, + lock_spi_device=True, + ) - def _load_cc1101_module(self): - if self.cc1101_module is not None: - return self.cc1101_module + def _load_cc1101_module(self, cc1101_module): + if cc1101_module is not None: + return cc1101_module try: import cc1101 return cc1101 @@ -55,12 +87,7 @@ def _load_cc1101_module(self): ) def transmit(self, waveform_sender, repetition): - cc1101_module = self._load_cc1101_module() - with cc1101_module.CC1101( - spi_bus=self.config.spi_bus, - spi_chip_select=self.config.spi_device, - lock_spi_device=True, - ) as radio: + with self.radio as radio: radio.set_base_frequency_hertz(self.config.frequency_hz) radio.set_symbol_rate_baud(self.config.symbol_rate_baud) radio.set_output_power(self.config.output_power_table) diff --git a/config.py b/config.py index c12ddaa..42ab87e 100644 --- a/config.py +++ b/config.py @@ -5,7 +5,6 @@ import logging, logging.handlers import threading import re -from cc1101_backend import CC1101Config try: from ConfigParser import RawConfigParser @@ -128,10 +127,6 @@ def __init__(self, filename = None, section = None, log = None): self.HTTPPort = 80 self.HTTPSPort = 443 self.RFBackend = "gpio" - self.CC1101Frequency = CC1101Config.DEFAULT_FREQUENCY_MHZ - self.CC1101SPIBus = CC1101Config.DEFAULT_SPI_BUS - self.CC1101SPIDevice = CC1101Config.DEFAULT_SPI_DEVICE - self.CC1101OutputPower = CC1101Config.DEFAULT_OUTPUT_POWER self.RTS_Address = "0x279620" self.MQTT_ClientID = "somfy-mqtt-bridge" self.Shutters = {} @@ -166,10 +161,6 @@ def LoadConfig(self): 'HTTPSPort': int, 'TXGPIO': int, 'RFBackend': str, - 'CC1101Frequency': float, - 'CC1101SPIBus': int, - 'CC1101SPIDevice': int, - 'CC1101OutputPower': int, 'RTS_Address': str, "Password": str } diff --git a/operateShutters.py b/operateShutters.py index 839c2ce..778954a 100755 --- a/operateShutters.py +++ b/operateShutters.py @@ -128,7 +128,7 @@ def registerCommand(self, commandDirection): self.lastCommandDirection = commandDirection self.lastCommandTime = time.monotonic() - def __init__(self, log = None, config = None): + def __init__(self, log = None, config = None, cc1101_module = None): super(Shutter, self).__init__() self.lock = threading.Lock() if log is not None: @@ -141,6 +141,12 @@ def __init__(self, log = None, config = None): else: self.TXGPIO=4 # 433.42 MHz emitter on GPIO 4 self.RFBackend = getattr(self.config, "RFBackend", "gpio").strip().lower() + self.cc1101_transmitter = None + if self.RFBackend == "cc1101": + try: + self.cc1101_transmitter = CC1101Transmitter(CC1101Config.from_app_config(self.config), cc1101_module=cc1101_module) + except RuntimeError as e: + self.FatalError(str(e)) self.frame = bytearray(7) self.callback = [] self.shutterStateList = {} @@ -376,11 +382,12 @@ def _sendWave_gpio(self, repetition): self._sendWave_pigpio(repetition) def _sendWave_cc1101(self, repetition, cc1101_module=None): - try: - transmitter = CC1101Transmitter(CC1101Config.from_app_config(self.config), cc1101_module=cc1101_module) - transmitter.transmit(self._sendWave_gpio, repetition) - except RuntimeError as e: - self.FatalError(str(e)) + if self.cc1101_transmitter is None: + try: + self.cc1101_transmitter = CC1101Transmitter(CC1101Config.from_app_config(self.config), cc1101_module=cc1101_module) + except RuntimeError as e: + self.FatalError(str(e)) + self.cc1101_transmitter.transmit(self._sendWave_gpio, repetition) def _sendWave_pigpio(self, repetition): #This is where all the awesomeness is happening. You're telling the daemon what you wanna send diff --git a/tests/test_cc1101_backend.py b/tests/test_cc1101_backend.py index ab5e728..2089b7f 100644 --- a/tests/test_cc1101_backend.py +++ b/tests/test_cc1101_backend.py @@ -59,6 +59,31 @@ def test_config_is_derived_from_app_config(self): self.assertEqual(0xC6, config.output_power) self.assertEqual(1562.5, config.symbol_rate_baud) + def test_config_reads_values_from_myconfig_interface(self): + class AppConfig: + def ReadValue(self, entry, return_type=str, default=None, section=None): + values = { + "CC1101Frequency": "433.42", + "CC1101SPIBus": "0", + "CC1101SPIDevice": "1", + "CC1101OutputPower": "0xC6", + } + value = values.get(entry) + if value is None: + return default + if return_type == int: + return int(value, 0) + if return_type == float: + return float(value) + return value + + config = CC1101Config.from_app_config(AppConfig()) + + self.assertEqual(433.42e6, config.frequency_hz) + self.assertEqual(0, config.spi_bus) + self.assertEqual(1, config.spi_device) + self.assertEqual(0xC6, config.output_power) + def test_transmitter_configures_radio_and_calls_waveform_callback(self): fake_cc1101 = types.SimpleNamespace(CC1101=FakeRadio) config = CC1101Config( @@ -91,6 +116,18 @@ def test_transmitter_configures_radio_and_calls_waveform_callback(self): radio.calls, ) + def test_transmitter_reuses_radio_object_between_transmits(self): + fake_cc1101 = types.SimpleNamespace(CC1101=FakeRadio) + config = CC1101Config() + calls = [] + + transmitter = CC1101Transmitter(config, cc1101_module=fake_cc1101) + transmitter.transmit(lambda repetition: calls.append(("waveform", repetition)), 1) + transmitter.transmit(lambda repetition: calls.append(("waveform", repetition)), 2) + + self.assertEqual([("waveform", 1), ("waveform", 2)], calls) + self.assertEqual(1, len(FakeRadio.instances)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_rf_backend.py b/tests/test_rf_backend.py index 9ea5a4f..9825ca1 100644 --- a/tests/test_rf_backend.py +++ b/tests/test_rf_backend.py @@ -105,11 +105,11 @@ def test_cc1101_backend_configures_radio_and_reuses_waveform(self): config.CC1101SPIBus = 0 config.CC1101SPIDevice = 0 config.CC1101OutputPower = 0xC6 - shutter = operateShutters.Shutter(config=config) fake_cc1101 = types.SimpleNamespace(CC1101=FakeRadio) + shutter = operateShutters.Shutter(config=config, cc1101_module=fake_cc1101) with mock.patch.object(shutter, "_sendWave_gpio") as send_wave: - shutter.sendCommand("279620", shutter.buttonDown, 3, cc1101_module=fake_cc1101) + shutter.sendCommand("279620", shutter.buttonDown, 3) self.assertEqual(1, len(FakeRadio.instances)) radio = FakeRadio.instances[0] @@ -123,6 +123,22 @@ def test_cc1101_backend_configures_radio_and_reuses_waveform(self): send_wave.assert_called_once_with(3) self.assertEqual(2, config.Shutters["279620"]["code"]) + def test_cc1101_backend_reuses_transmitter_between_commands(self): + config = FakeConfig() + config.RFBackend = "cc1101" + shutter = operateShutters.Shutter( + config=config, + cc1101_module=types.SimpleNamespace(CC1101=FakeRadio), + ) + + with mock.patch.object(shutter, "_sendWave_gpio") as send_wave: + shutter.sendCommand("279620", shutter.buttonUp, 1) + shutter.sendCommand("279620", shutter.buttonStop, 1) + + self.assertEqual(1, len(FakeRadio.instances)) + self.assertEqual([mock.call(1), mock.call(1)], send_wave.call_args_list) + self.assertEqual(3, config.Shutters["279620"]["code"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_rf_config.py b/tests/test_rf_config.py index 31a2f24..081605b 100644 --- a/tests/test_rf_config.py +++ b/tests/test_rf_config.py @@ -42,10 +42,10 @@ def test_defaults_to_gpio_backend_for_existing_configs(self): ) self.assertEqual("gpio", config.RFBackend) - self.assertEqual(433.42, config.CC1101Frequency) - self.assertEqual(0, config.CC1101SPIBus) - self.assertEqual(0, config.CC1101SPIDevice) - self.assertEqual(0xC6, config.CC1101OutputPower) + self.assertFalse(hasattr(config, "CC1101Frequency")) + self.assertFalse(hasattr(config, "CC1101SPIBus")) + self.assertFalse(hasattr(config, "CC1101SPIDevice")) + self.assertFalse(hasattr(config, "CC1101OutputPower")) def test_parses_cc1101_backend_options(self): config = self._load_config( @@ -75,10 +75,10 @@ def test_parses_cc1101_backend_options(self): ) self.assertEqual("cc1101", config.RFBackend) - self.assertEqual(433.42, config.CC1101Frequency) - self.assertEqual(0, config.CC1101SPIBus) - self.assertEqual(0, config.CC1101SPIDevice) - self.assertEqual(0xC6, config.CC1101OutputPower) + self.assertFalse(hasattr(config, "CC1101Frequency")) + self.assertFalse(hasattr(config, "CC1101SPIBus")) + self.assertFalse(hasattr(config, "CC1101SPIDevice")) + self.assertFalse(hasattr(config, "CC1101OutputPower")) if __name__ == "__main__": From a91aededb73fe5da8f2953c6fd5cafe5ad0dd0ae Mon Sep 17 00:00:00 2001 From: Idan Freiberg Date: Fri, 5 Jun 2026 06:38:20 +0300 Subject: [PATCH 04/13] refactor: generalize rf transmitter backends --- README.md | 2 +- cc1101_backend.py | 7 +- defaultConfig.conf | 9 +- gpio_backend.py | 161 +++++++++++++++++++++++++++++++ operateShutters.py | 149 +++------------------------- rf_backend.py | 38 ++++++++ tests/test_cc1101_backend.py | 16 +-- tests/test_gpio_backend.py | 135 ++++++++++++++++++++++++++ tests/test_rf_backend.py | 25 ++--- tests/test_rf_backend_factory.py | 58 +++++++++++ 10 files changed, 440 insertions(+), 160 deletions(-) create mode 100644 gpio_backend.py create mode 100644 rf_backend.py create mode 100644 tests/test_gpio_backend.py create mode 100644 tests/test_rf_backend_factory.py diff --git a/README.md b/README.md index dfc0397..74ecce5 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ The E07-M1101D-SMA must be powered from 3.3V. Do not connect VCC or any logic pi | 7 MISO/GDO1 | 21 | SPI0 MISO / GPIO9 | | 8 GDO2 | 22 | GPIO25, optional | -Enable SPI on the Pi, then set the RF backend in `operateShutters.conf`: +Enable SPI on the Pi, then set the top-level RF backend in `operateShutters.conf`: ```ini TXGPIO = 4 diff --git a/cc1101_backend.py b/cc1101_backend.py index 4ec2498..d51388e 100644 --- a/cc1101_backend.py +++ b/cc1101_backend.py @@ -66,8 +66,9 @@ def output_power_table(self): class CC1101Transmitter: - def __init__(self, config, cc1101_module=None): + def __init__(self, config, waveform_transmitter, cc1101_module=None): self.config = config + self.waveform_transmitter = waveform_transmitter cc1101_module = self._load_cc1101_module(cc1101_module) self.radio = cc1101_module.CC1101( spi_bus=self.config.spi_bus, @@ -86,10 +87,10 @@ def _load_cc1101_module(self, cc1101_module): "RFBackend=cc1101 requires the cc1101 Python package: " + str(e) ) - def transmit(self, waveform_sender, repetition): + def transmit(self, frame, repetition): with self.radio as radio: radio.set_base_frequency_hertz(self.config.frequency_hz) radio.set_symbol_rate_baud(self.config.symbol_rate_baud) radio.set_output_power(self.config.output_power_table) with radio.asynchronous_transmission(): - waveform_sender(repetition) + self.waveform_transmitter.transmit(frame, repetition) diff --git a/defaultConfig.conf b/defaultConfig.conf index 51bde17..4ed3b12 100644 --- a/defaultConfig.conf +++ b/defaultConfig.conf @@ -25,12 +25,12 @@ SendRepeat = 2 # emitter is connected to. The default value is 4 TXGPIO = 4 -# (Optional) RF backend used for transmission. Keep "gpio" for the original -# 3-pin 433.42 MHz transmitter. Use "cc1101" for modules such as the -# E07-M1101D-SMA, with GDO0 wired to TXGPIO. +# (Optional) RF backend used for transmission. Supported values: +# gpio - original 3-pin 433.42 MHz transmitter +# cc1101 - CC1101 modules such as the E07-M1101D-SMA, with GDO0 wired to TXGPIO RFBackend = gpio -# (Optional) CC1101 settings used only when RFBackend = cc1101. +# (Optional) CC1101 backend settings used only when RFBackend = cc1101. # The E07-M1101D-SMA is a 3.3V SPI CC1101 module. Do not power it from 5V. CC1101Frequency = 433.42 CC1101SPIBus = 0 @@ -142,4 +142,3 @@ EnableDiscovery = true # - shutterIds: Array of shutterIds to operate # [Scheduler] - diff --git a/gpio_backend.py b/gpio_backend.py new file mode 100644 index 0000000..93cec81 --- /dev/null +++ b/gpio_backend.py @@ -0,0 +1,161 @@ +#!/usr/bin/python3 + +import time + + +class GPIOConfig: + DEFAULT_TXGPIO = 4 + + def __init__(self, tx_gpio=DEFAULT_TXGPIO): + self.tx_gpio = int(tx_gpio) + + @classmethod + def from_app_config(cls, config): + if hasattr(config, "ReadValue"): + return cls( + tx_gpio=config.ReadValue( + "TXGPIO", + return_type=int, + default=cls.DEFAULT_TXGPIO, + section="General", + ) + ) + return cls(tx_gpio=getattr(config, "TXGPIO", cls.DEFAULT_TXGPIO)) + + +class GPIOTransmitter: + def __init__( + self, + config, + is_pi5=False, + pigpio_module=None, + lgpio_module=None, + lgpio_chip=4, + ): + self.config = config + self.is_pi5 = is_pi5 + self.pigpio = pigpio_module + self.lgpio = lgpio_module + self.lgpio_chip = lgpio_chip + + def transmit(self, frame, repetition): + if self.is_pi5: + self._send_lgpio(frame, repetition) + else: + self._send_pigpio(frame, repetition) + + def _load_pigpio(self): + if self.pigpio is not None: + return self.pigpio + import pigpio + return pigpio + + def _load_lgpio(self): + if self.lgpio is not None: + return self.lgpio + import lgpio + return lgpio + + def _send_pigpio(self, frame, repetition): + pigpio = self._load_pigpio() + pi = pigpio.pi() + + if not pi.connected: + exit() + + tx_gpio = self.config.tx_gpio + pi.wave_add_new() + pi.set_mode(tx_gpio, pigpio.OUTPUT) + + wf = [] + wf.append(pigpio.pulse(1 << tx_gpio, 0, 9415)) # wake up pulse + wf.append(pigpio.pulse(0, 1 << tx_gpio, 89565)) # silence + for i in range(2): # hardware synchronization + wf.append(pigpio.pulse(1 << tx_gpio, 0, 2560)) + wf.append(pigpio.pulse(0, 1 << tx_gpio, 2560)) + wf.append(pigpio.pulse(1 << tx_gpio, 0, 4550)) # software synchronization + wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) + + for i in range(0, 56): # manchester encoding of payload data + if ((frame[int(i / 8)] >> (7 - (i % 8))) & 1): + wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) + wf.append(pigpio.pulse(1 << tx_gpio, 0, 640)) + else: + wf.append(pigpio.pulse(1 << tx_gpio, 0, 640)) + wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) + + wf.append(pigpio.pulse(0, 1 << tx_gpio, 30415)) # interframe gap + + for j in range(1, repetition): # repeating frames + for i in range(7): # hardware synchronization + wf.append(pigpio.pulse(1 << tx_gpio, 0, 2560)) + wf.append(pigpio.pulse(0, 1 << tx_gpio, 2560)) + wf.append(pigpio.pulse(1 << tx_gpio, 0, 4550)) # software synchronization + wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) + + for i in range(0, 56): # manchester encoding of payload data + if ((frame[int(i / 8)] >> (7 - (i % 8))) & 1): + wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) + wf.append(pigpio.pulse(1 << tx_gpio, 0, 640)) + else: + wf.append(pigpio.pulse(1 << tx_gpio, 0, 640)) + wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) + + wf.append(pigpio.pulse(0, 1 << tx_gpio, 30415)) # interframe gap + + pi.wave_add_generic(wf) + wid = pi.wave_create() + pi.wave_send_once(wid) + while pi.wave_tx_busy(): + pass + pi.wave_delete(wid) + pi.stop() + + def _send_lgpio(self, frame, repetition): + lgpio = self._load_lgpio() + tx_gpio = self.config.tx_gpio + h = lgpio.gpiochip_open(self.lgpio_chip) + lgpio.gpio_claim_output(h, tx_gpio) + + pulses = [] + pulses.append(lgpio.pulse(1, 1, 9415)) # wake up pulse + pulses.append(lgpio.pulse(0, 1, 89565)) # silence + for i in range(2): # hardware synchronization + pulses.append(lgpio.pulse(1, 1, 2560)) + pulses.append(lgpio.pulse(0, 1, 2560)) + pulses.append(lgpio.pulse(1, 1, 4550)) # software synchronization + pulses.append(lgpio.pulse(0, 1, 640)) + + for i in range(0, 56): # manchester encoding of payload data + if ((frame[int(i / 8)] >> (7 - (i % 8))) & 1): + pulses.append(lgpio.pulse(0, 1, 640)) + pulses.append(lgpio.pulse(1, 1, 640)) + else: + pulses.append(lgpio.pulse(1, 1, 640)) + pulses.append(lgpio.pulse(0, 1, 640)) + + pulses.append(lgpio.pulse(0, 1, 30415)) # interframe gap + + for j in range(1, repetition): # repeating frames + for i in range(7): # hardware synchronization + pulses.append(lgpio.pulse(1, 1, 2560)) + pulses.append(lgpio.pulse(0, 1, 2560)) + pulses.append(lgpio.pulse(1, 1, 4550)) # software synchronization + pulses.append(lgpio.pulse(0, 1, 640)) + + for i in range(0, 56): # manchester encoding of payload data + if ((frame[int(i / 8)] >> (7 - (i % 8))) & 1): + pulses.append(lgpio.pulse(0, 1, 640)) + pulses.append(lgpio.pulse(1, 1, 640)) + else: + pulses.append(lgpio.pulse(1, 1, 640)) + pulses.append(lgpio.pulse(0, 1, 640)) + + pulses.append(lgpio.pulse(0, 1, 30415)) # interframe gap + + lgpio.tx_wave(h, tx_gpio, pulses) + while lgpio.tx_busy(h, tx_gpio, lgpio.TX_WAVE): + time.sleep(0.001) + + lgpio.gpio_free(h, tx_gpio) + lgpio.gpiochip_close(h) diff --git a/operateShutters.py b/operateShutters.py index 778954a..54d08d3 100755 --- a/operateShutters.py +++ b/operateShutters.py @@ -101,8 +101,7 @@ def __init__(self, *a, **kw): pass from webserver import FlaskAppWrapper from alexa import Alexa from shutil import copyfile - from cc1101_backend import CC1101Config - from cc1101_backend import CC1101Transmitter + 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)) @@ -128,7 +127,7 @@ def registerCommand(self, commandDirection): self.lastCommandDirection = commandDirection self.lastCommandTime = time.monotonic() - def __init__(self, log = None, config = None, cc1101_module = None): + def __init__(self, log = None, config = None, rf_transmitter = None, cc1101_module = None): super(Shutter, self).__init__() self.lock = threading.Lock() if log is not None: @@ -141,11 +140,18 @@ def __init__(self, log = None, config = None, cc1101_module = None): else: self.TXGPIO=4 # 433.42 MHz emitter on GPIO 4 self.RFBackend = getattr(self.config, "RFBackend", "gpio").strip().lower() - self.cc1101_transmitter = None - if self.RFBackend == "cc1101": + self.rf_transmitter = rf_transmitter + if self.rf_transmitter is None: try: - self.cc1101_transmitter = CC1101Transmitter(CC1101Config.from_app_config(self.config), cc1101_module=cc1101_module) - except RuntimeError as e: + self.rf_transmitter = create_transmitter( + self.config, + is_pi5=IS_PI5, + pigpio_module=globals().get("pigpio"), + lgpio_module=globals().get("lgpio"), + cc1101_module=cc1101_module, + lgpio_chip=LGPIO_CHIP, + ) + except (RuntimeError, ValueError) as e: self.FatalError(str(e)) self.frame = bytearray(7) self.callback = [] @@ -306,7 +312,7 @@ def program(self, shutterId): def registerCallBack(self, callbackFunction): self.callback.append(callbackFunction) - def sendCommand(self, shutterId, button, repetition, cc1101_module=None): #Sending a frame + 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. @@ -362,136 +368,11 @@ def sendCommand(self, shutterId, button, repetition, cc1101_module=None): #Sendi outstring = outstring + "0x%0.2X" % octet + ' ' self.LogInfo (outstring) - self._sendWave(repetition, cc1101_module=cc1101_module) + self.rf_transmitter.transmit(self.frame, repetition) finally: self.lock.release() self.LogDebug("sendCommand: Lock released") - def _sendWave(self, repetition, cc1101_module=None): - if self.RFBackend == "gpio": - self._sendWave_gpio(repetition) - elif self.RFBackend == "cc1101": - self._sendWave_cc1101(repetition, cc1101_module=cc1101_module) - else: - self.FatalError("Unsupported RFBackend: " + str(self.RFBackend)) - - def _sendWave_gpio(self, repetition): - if IS_PI5: - self._sendWave_lgpio(repetition) - else: - self._sendWave_pigpio(repetition) - - def _sendWave_cc1101(self, repetition, cc1101_module=None): - if self.cc1101_transmitter is None: - try: - self.cc1101_transmitter = CC1101Transmitter(CC1101Config.from_app_config(self.config), cc1101_module=cc1101_module) - except RuntimeError as e: - self.FatalError(str(e)) - self.cc1101_transmitter.transmit(self._sendWave_gpio, repetition) - - def _sendWave_pigpio(self, repetition): - #This is where all the awesomeness is happening. You're telling the daemon what you wanna send - pi = pigpio.pi() # connect to Pi - - if not pi.connected: - exit() - - pi.wave_add_new() - pi.set_mode(self.TXGPIO, pigpio.OUTPUT) - - wf=[] - wf.append(pigpio.pulse(1<> (7 - (i%8))) & 1): - wf.append(pigpio.pulse(0, 1<> (7 - (i%8))) & 1): - wf.append(pigpio.pulse(0, 1<> (7 - (i%8))) & 1): - pulses.append(lgpio.pulse(0, 1, 640)) - pulses.append(lgpio.pulse(1, 1, 640)) - else: - pulses.append(lgpio.pulse(1, 1, 640)) - pulses.append(lgpio.pulse(0, 1, 640)) - - pulses.append(lgpio.pulse(0, 1, 30415)) # interframe gap - - for j in range(1, repetition): # repeating frames - for i in range(7): # hardware synchronization - pulses.append(lgpio.pulse(1, 1, 2560)) - pulses.append(lgpio.pulse(0, 1, 2560)) - pulses.append(lgpio.pulse(1, 1, 4550)) # software synchronization - pulses.append(lgpio.pulse(0, 1, 640)) - - for i in range(0, 56): # manchester encoding of payload data - if ((self.frame[int(i/8)] >> (7 - (i%8))) & 1): - pulses.append(lgpio.pulse(0, 1, 640)) - pulses.append(lgpio.pulse(1, 1, 640)) - else: - pulses.append(lgpio.pulse(1, 1, 640)) - pulses.append(lgpio.pulse(0, 1, 640)) - - pulses.append(lgpio.pulse(0, 1, 30415)) # interframe gap - - lgpio.tx_wave(h, self.TXGPIO, pulses) - while lgpio.tx_busy(h, self.TXGPIO, lgpio.TX_WAVE): - time.sleep(0.001) - - lgpio.gpio_free(h, self.TXGPIO) - lgpio.gpiochip_close(h) - class operateShutters(MyLog): def __init__(self, args = None): diff --git a/rf_backend.py b/rf_backend.py new file mode 100644 index 0000000..a50a6fa --- /dev/null +++ b/rf_backend.py @@ -0,0 +1,38 @@ +#!/usr/bin/python3 + +from cc1101_backend import CC1101Config +from cc1101_backend import CC1101Transmitter +from gpio_backend import GPIOConfig +from gpio_backend import GPIOTransmitter + + +def get_backend_name(config): + return getattr(config, "RFBackend", "gpio").strip().lower() + + +def create_transmitter( + config, + is_pi5=False, + pigpio_module=None, + lgpio_module=None, + cc1101_module=None, + lgpio_chip=4, +): + backend_name = get_backend_name(config) + gpio_transmitter = GPIOTransmitter( + GPIOConfig.from_app_config(config), + is_pi5=is_pi5, + pigpio_module=pigpio_module, + lgpio_module=lgpio_module, + lgpio_chip=lgpio_chip, + ) + + if backend_name == "gpio": + return gpio_transmitter + if backend_name == "cc1101": + return CC1101Transmitter( + CC1101Config.from_app_config(config), + gpio_transmitter, + cc1101_module=cc1101_module, + ) + raise ValueError("Unsupported RFBackend: " + str(backend_name)) diff --git a/tests/test_cc1101_backend.py b/tests/test_cc1101_backend.py index 2089b7f..7020411 100644 --- a/tests/test_cc1101_backend.py +++ b/tests/test_cc1101_backend.py @@ -92,12 +92,14 @@ def test_transmitter_configures_radio_and_calls_waveform_callback(self): spi_device=0, output_power=0xC6, ) + waveform_transmitter = types.SimpleNamespace(transmit=lambda frame, repetition: calls.append(("waveform", frame, repetition))) + frame = bytearray([0] * 7) calls = [] - transmitter = CC1101Transmitter(config, cc1101_module=fake_cc1101) - transmitter.transmit(lambda repetition: calls.append(("waveform", repetition)), 3) + transmitter = CC1101Transmitter(config, waveform_transmitter, cc1101_module=fake_cc1101) + transmitter.transmit(frame, 3) - self.assertEqual([("waveform", 3)], calls) + self.assertEqual([("waveform", frame, 3)], calls) self.assertEqual(1, len(FakeRadio.instances)) radio = FakeRadio.instances[0] self.assertEqual(0, radio.spi_bus) @@ -119,11 +121,13 @@ def test_transmitter_configures_radio_and_calls_waveform_callback(self): def test_transmitter_reuses_radio_object_between_transmits(self): fake_cc1101 = types.SimpleNamespace(CC1101=FakeRadio) config = CC1101Config() + waveform_transmitter = types.SimpleNamespace(transmit=lambda frame, repetition: calls.append(("waveform", repetition))) + frame = bytearray([0] * 7) calls = [] - transmitter = CC1101Transmitter(config, cc1101_module=fake_cc1101) - transmitter.transmit(lambda repetition: calls.append(("waveform", repetition)), 1) - transmitter.transmit(lambda repetition: calls.append(("waveform", repetition)), 2) + transmitter = CC1101Transmitter(config, waveform_transmitter, cc1101_module=fake_cc1101) + transmitter.transmit(frame, 1) + transmitter.transmit(frame, 2) self.assertEqual([("waveform", 1), ("waveform", 2)], calls) self.assertEqual(1, len(FakeRadio.instances)) diff --git a/tests/test_gpio_backend.py b/tests/test_gpio_backend.py new file mode 100644 index 0000000..e172913 --- /dev/null +++ b/tests/test_gpio_backend.py @@ -0,0 +1,135 @@ +import unittest + +from gpio_backend import GPIOConfig, GPIOTransmitter + + +class FakePi: + instances = [] + + def __init__(self): + self.connected = True + self.calls = [] + FakePi.instances.append(self) + + def wave_add_new(self): + self.calls.append(("wave_add_new",)) + + def set_mode(self, gpio, mode): + self.calls.append(("set_mode", gpio, mode)) + + def wave_add_generic(self, waveform): + self.calls.append(("wave_add_generic", waveform)) + + def wave_create(self): + self.calls.append(("wave_create",)) + return 7 + + def wave_send_once(self, wave_id): + self.calls.append(("wave_send_once", wave_id)) + + def wave_tx_busy(self): + self.calls.append(("wave_tx_busy",)) + return False + + def wave_delete(self, wave_id): + self.calls.append(("wave_delete", wave_id)) + + def stop(self): + self.calls.append(("stop",)) + + +class FakePigpio: + OUTPUT = "output" + + def pi(self): + return FakePi() + + def pulse(self, gpio_on, gpio_off, delay): + return ("pulse", gpio_on, gpio_off, delay) + + +class FakeLgpio: + TX_WAVE = "tx_wave" + + def __init__(self): + self.calls = [] + + def gpiochip_open(self, chip): + self.calls.append(("gpiochip_open", chip)) + return "handle" + + def gpio_claim_output(self, handle, gpio): + self.calls.append(("gpio_claim_output", handle, gpio)) + + def pulse(self, level, mask, delay): + return ("pulse", level, mask, delay) + + def tx_wave(self, handle, gpio, pulses): + self.calls.append(("tx_wave", handle, gpio, pulses)) + + def tx_busy(self, handle, gpio, tx_type): + self.calls.append(("tx_busy", handle, gpio, tx_type)) + return False + + def gpio_free(self, handle, gpio): + self.calls.append(("gpio_free", handle, gpio)) + + def gpiochip_close(self, handle): + self.calls.append(("gpiochip_close", handle)) + + +class GPIOBackendTest(unittest.TestCase): + def setUp(self): + FakePi.instances = [] + + def test_config_reads_tx_gpio_from_app_config(self): + class AppConfig: + def ReadValue(self, entry, return_type=str, default=None, section=None): + if entry == "TXGPIO": + return 17 + return default + + config = GPIOConfig.from_app_config(AppConfig()) + + self.assertEqual(17, config.tx_gpio) + + def test_pigpio_transmitter_sends_waveform(self): + transmitter = GPIOTransmitter( + GPIOConfig(tx_gpio=4), + is_pi5=False, + pigpio_module=FakePigpio(), + ) + frame = bytearray([0] * 7) + + transmitter.transmit(frame, 1) + + fake_pi = FakePi.instances[0] + self.assertIn(("set_mode", 4, "output"), fake_pi.calls) + wave_call = [call for call in fake_pi.calls if call[0] == "wave_add_generic"][0] + waveform = wave_call[1] + self.assertEqual(("pulse", 1 << 4, 0, 9415), waveform[0]) + self.assertIn(("wave_send_once", 7), fake_pi.calls) + self.assertIn(("stop",), fake_pi.calls) + + def test_lgpio_transmitter_sends_waveform(self): + fake_lgpio = FakeLgpio() + transmitter = GPIOTransmitter( + GPIOConfig(tx_gpio=4), + is_pi5=True, + lgpio_module=fake_lgpio, + lgpio_chip=4, + ) + frame = bytearray([0] * 7) + + transmitter.transmit(frame, 1) + + self.assertEqual(("gpiochip_open", 4), fake_lgpio.calls[0]) + self.assertEqual(("gpio_claim_output", "handle", 4), fake_lgpio.calls[1]) + tx_wave_call = [call for call in fake_lgpio.calls if call[0] == "tx_wave"][0] + self.assertEqual("handle", tx_wave_call[1]) + self.assertEqual(4, tx_wave_call[2]) + self.assertEqual(("pulse", 1, 1, 9415), tx_wave_call[3][0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rf_backend.py b/tests/test_rf_backend.py index 9825ca1..09ac515 100644 --- a/tests/test_rf_backend.py +++ b/tests/test_rf_backend.py @@ -90,12 +90,12 @@ def setUp(self): def test_gpio_backend_uses_existing_waveform_path(self): config = FakeConfig() config.RFBackend = "gpio" - shutter = operateShutters.Shutter(config=config) + transmitter = mock.Mock() + shutter = operateShutters.Shutter(config=config, rf_transmitter=transmitter) - with mock.patch.object(shutter, "_sendWave_gpio") as send_wave: - shutter.sendCommand("279620", shutter.buttonUp, 2) + shutter.sendCommand("279620", shutter.buttonUp, 2) - send_wave.assert_called_once_with(2) + transmitter.transmit.assert_called_once_with(shutter.frame, 2) self.assertEqual(2, config.Shutters["279620"]["code"]) def test_cc1101_backend_configures_radio_and_reuses_waveform(self): @@ -107,9 +107,9 @@ def test_cc1101_backend_configures_radio_and_reuses_waveform(self): config.CC1101OutputPower = 0xC6 fake_cc1101 = types.SimpleNamespace(CC1101=FakeRadio) shutter = operateShutters.Shutter(config=config, cc1101_module=fake_cc1101) + shutter.rf_transmitter.waveform_transmitter.transmit = mock.Mock() - with mock.patch.object(shutter, "_sendWave_gpio") as send_wave: - shutter.sendCommand("279620", shutter.buttonDown, 3) + shutter.sendCommand("279620", shutter.buttonDown, 3) self.assertEqual(1, len(FakeRadio.instances)) radio = FakeRadio.instances[0] @@ -120,7 +120,7 @@ def test_cc1101_backend_configures_radio_and_reuses_waveform(self): self.assertIn(("symbol_rate", 1562.5), radio.calls) self.assertIn(("output_power", (0, 0xC6)), radio.calls) self.assertLess(radio.calls.index(("async_enter",)), radio.calls.index(("async_exit",))) - send_wave.assert_called_once_with(3) + shutter.rf_transmitter.waveform_transmitter.transmit.assert_called_once_with(shutter.frame, 3) self.assertEqual(2, config.Shutters["279620"]["code"]) def test_cc1101_backend_reuses_transmitter_between_commands(self): @@ -131,12 +131,15 @@ def test_cc1101_backend_reuses_transmitter_between_commands(self): cc1101_module=types.SimpleNamespace(CC1101=FakeRadio), ) - with mock.patch.object(shutter, "_sendWave_gpio") as send_wave: - shutter.sendCommand("279620", shutter.buttonUp, 1) - shutter.sendCommand("279620", shutter.buttonStop, 1) + shutter.rf_transmitter.waveform_transmitter.transmit = mock.Mock() + shutter.sendCommand("279620", shutter.buttonUp, 1) + shutter.sendCommand("279620", shutter.buttonStop, 1) self.assertEqual(1, len(FakeRadio.instances)) - self.assertEqual([mock.call(1), mock.call(1)], send_wave.call_args_list) + self.assertEqual( + [mock.call(shutter.frame, 1), mock.call(shutter.frame, 1)], + shutter.rf_transmitter.waveform_transmitter.transmit.call_args_list, + ) self.assertEqual(3, config.Shutters["279620"]["code"]) diff --git a/tests/test_rf_backend_factory.py b/tests/test_rf_backend_factory.py new file mode 100644 index 0000000..f5baa38 --- /dev/null +++ b/tests/test_rf_backend_factory.py @@ -0,0 +1,58 @@ +import types +import unittest + +from cc1101_backend import CC1101Transmitter +from gpio_backend import GPIOTransmitter +from rf_backend import create_transmitter + + +class FakeRadio: + def __init__(self, spi_bus, spi_chip_select, lock_spi_device): + pass + + +class FakePigpio: + OUTPUT = "output" + + +class BackendFactoryTest(unittest.TestCase): + def test_creates_gpio_transmitter_by_default(self): + config = types.SimpleNamespace(RFBackend="gpio", TXGPIO=4) + + transmitter = create_transmitter( + config, + is_pi5=False, + pigpio_module=FakePigpio(), + ) + + self.assertIsInstance(transmitter, GPIOTransmitter) + + def test_creates_cc1101_transmitter_wrapping_gpio_transmitter(self): + config = types.SimpleNamespace( + RFBackend="cc1101", + TXGPIO=4, + CC1101Frequency=433.42, + CC1101SPIBus=0, + CC1101SPIDevice=0, + CC1101OutputPower=0xC6, + ) + + transmitter = create_transmitter( + config, + is_pi5=False, + pigpio_module=FakePigpio(), + cc1101_module=types.SimpleNamespace(CC1101=FakeRadio), + ) + + self.assertIsInstance(transmitter, CC1101Transmitter) + self.assertIsInstance(transmitter.waveform_transmitter, GPIOTransmitter) + + def test_rejects_unknown_backend(self): + config = types.SimpleNamespace(RFBackend="other", TXGPIO=4) + + with self.assertRaisesRegex(ValueError, "Unsupported RFBackend"): + create_transmitter(config, is_pi5=False, pigpio_module=FakePigpio()) + + +if __name__ == "__main__": + unittest.main() From a949f44ff516266900b786386ca73f02517c47de Mon Sep 17 00:00:00 2001 From: Idan Freiberg Date: Fri, 5 Jun 2026 06:53:50 +0300 Subject: [PATCH 05/13] refactor: rename raw 433 rf backend --- README.md | 9 + config.py | 2 +- defaultConfig.conf | 7 +- .../plans/2026-06-05-e07-m1101d-sma-cc1101.md | 6 +- gpio_backend.py | 161 +----------------- operateShutters.py | 2 +- raw_433_backend.py | 161 ++++++++++++++++++ rf_backend.py | 19 ++- ...pio_backend.py => test_raw_433_backend.py} | 14 +- tests/test_rf_backend.py | 6 +- tests/test_rf_backend_factory.py | 23 ++- tests/test_rf_config.py | 4 +- 12 files changed, 224 insertions(+), 190 deletions(-) create mode 100644 raw_433_backend.py rename tests/{test_gpio_backend.py => test_raw_433_backend.py} (91%) diff --git a/README.md b/README.md index 74ecce5..d838041 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,15 @@ CC1101SPIDevice = 0 CC1101OutputPower = 0xC6 ``` +For the original raw 433 MHz ASK/OOK transmitter module, use: + +```ini +TXGPIO = 4 +RFBackend = raw_433 +``` + +The older `RFBackend = gpio` value is still accepted as an alias for `raw_433`. + ![Full Picture](documentation/Full%20Assembly.jpg)
![Pi Connection](documentation/Connection.jpg)
diff --git a/config.py b/config.py index 42ab87e..4dee540 100644 --- a/config.py +++ b/config.py @@ -126,7 +126,7 @@ def __init__(self, filename = None, section = None, log = None): self.UseHttps = False self.HTTPPort = 80 self.HTTPSPort = 443 - self.RFBackend = "gpio" + self.RFBackend = "raw_433" self.RTS_Address = "0x279620" self.MQTT_ClientID = "somfy-mqtt-bridge" self.Shutters = {} diff --git a/defaultConfig.conf b/defaultConfig.conf index 4ed3b12..3a32673 100644 --- a/defaultConfig.conf +++ b/defaultConfig.conf @@ -26,9 +26,10 @@ SendRepeat = 2 TXGPIO = 4 # (Optional) RF backend used for transmission. Supported values: -# gpio - original 3-pin 433.42 MHz transmitter -# cc1101 - CC1101 modules such as the E07-M1101D-SMA, with GDO0 wired to TXGPIO -RFBackend = gpio +# raw_433 - raw 433.42 MHz ASK/OOK transmitter driven from TXGPIO +# cc1101 - CC1101 modules such as the E07-M1101D-SMA, with GDO0 wired to TXGPIO +# Legacy value "gpio" is accepted as an alias for raw_433. +RFBackend = raw_433 # (Optional) CC1101 backend settings used only when RFBackend = cc1101. # The E07-M1101D-SMA is a 3.3V SPI CC1101 module. Do not power it from 5V. diff --git a/docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md b/docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md index 1647bea..2b592fe 100644 --- a/docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md +++ b/docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md @@ -27,10 +27,10 @@ - Create: `tests/test_rf_backend.py` - Modify: `operateShutters.py` -- [ ] Write a test that instantiates `Shutter` with `RFBackend = gpio` and patches `_sendWave_gpio` to assert it is called. -- [ ] Write a test that instantiates `Shutter` with `RFBackend = cc1101`, injects a fake CC1101 module, patches `_sendWave_gpio`, and asserts the radio is configured and `asynchronous_transmission()` wraps the waveform call. +- [ ] Write a test that instantiates `Shutter` with `RFBackend = raw_433` and patches the raw 433 transmitter to assert it is called. +- [ ] Write a test that instantiates `Shutter` with `RFBackend = cc1101`, injects a fake CC1101 module, patches the raw 433 transmitter, and asserts the radio is configured and `asynchronous_transmission()` wraps the waveform call. - [ ] Run `python3 -m unittest tests.test_rf_backend -v` and verify it fails because backend dispatch does not exist yet. -- [ ] Extract the existing pigpio transmit block into `_sendWave_pigpio`, keep the existing lgpio path, add `_sendWave_gpio`, and add `_sendWave_cc1101`. +- [ ] Extract the existing pigpio and lgpio transmit paths into a raw 433 transmitter backend, then add a CC1101 backend that wraps that waveform path. - [ ] Re-run backend tests and verify they pass. ### Task 3: Dependencies And Docs diff --git a/gpio_backend.py b/gpio_backend.py index 93cec81..22c752e 100644 --- a/gpio_backend.py +++ b/gpio_backend.py @@ -1,161 +1,8 @@ #!/usr/bin/python3 -import time +from raw_433_backend import Raw433Config +from raw_433_backend import Raw433Transmitter -class GPIOConfig: - DEFAULT_TXGPIO = 4 - - def __init__(self, tx_gpio=DEFAULT_TXGPIO): - self.tx_gpio = int(tx_gpio) - - @classmethod - def from_app_config(cls, config): - if hasattr(config, "ReadValue"): - return cls( - tx_gpio=config.ReadValue( - "TXGPIO", - return_type=int, - default=cls.DEFAULT_TXGPIO, - section="General", - ) - ) - return cls(tx_gpio=getattr(config, "TXGPIO", cls.DEFAULT_TXGPIO)) - - -class GPIOTransmitter: - def __init__( - self, - config, - is_pi5=False, - pigpio_module=None, - lgpio_module=None, - lgpio_chip=4, - ): - self.config = config - self.is_pi5 = is_pi5 - self.pigpio = pigpio_module - self.lgpio = lgpio_module - self.lgpio_chip = lgpio_chip - - def transmit(self, frame, repetition): - if self.is_pi5: - self._send_lgpio(frame, repetition) - else: - self._send_pigpio(frame, repetition) - - def _load_pigpio(self): - if self.pigpio is not None: - return self.pigpio - import pigpio - return pigpio - - def _load_lgpio(self): - if self.lgpio is not None: - return self.lgpio - import lgpio - return lgpio - - def _send_pigpio(self, frame, repetition): - pigpio = self._load_pigpio() - pi = pigpio.pi() - - if not pi.connected: - exit() - - tx_gpio = self.config.tx_gpio - pi.wave_add_new() - pi.set_mode(tx_gpio, pigpio.OUTPUT) - - wf = [] - wf.append(pigpio.pulse(1 << tx_gpio, 0, 9415)) # wake up pulse - wf.append(pigpio.pulse(0, 1 << tx_gpio, 89565)) # silence - for i in range(2): # hardware synchronization - wf.append(pigpio.pulse(1 << tx_gpio, 0, 2560)) - wf.append(pigpio.pulse(0, 1 << tx_gpio, 2560)) - wf.append(pigpio.pulse(1 << tx_gpio, 0, 4550)) # software synchronization - wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) - - for i in range(0, 56): # manchester encoding of payload data - if ((frame[int(i / 8)] >> (7 - (i % 8))) & 1): - wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) - wf.append(pigpio.pulse(1 << tx_gpio, 0, 640)) - else: - wf.append(pigpio.pulse(1 << tx_gpio, 0, 640)) - wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) - - wf.append(pigpio.pulse(0, 1 << tx_gpio, 30415)) # interframe gap - - for j in range(1, repetition): # repeating frames - for i in range(7): # hardware synchronization - wf.append(pigpio.pulse(1 << tx_gpio, 0, 2560)) - wf.append(pigpio.pulse(0, 1 << tx_gpio, 2560)) - wf.append(pigpio.pulse(1 << tx_gpio, 0, 4550)) # software synchronization - wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) - - for i in range(0, 56): # manchester encoding of payload data - if ((frame[int(i / 8)] >> (7 - (i % 8))) & 1): - wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) - wf.append(pigpio.pulse(1 << tx_gpio, 0, 640)) - else: - wf.append(pigpio.pulse(1 << tx_gpio, 0, 640)) - wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) - - wf.append(pigpio.pulse(0, 1 << tx_gpio, 30415)) # interframe gap - - pi.wave_add_generic(wf) - wid = pi.wave_create() - pi.wave_send_once(wid) - while pi.wave_tx_busy(): - pass - pi.wave_delete(wid) - pi.stop() - - def _send_lgpio(self, frame, repetition): - lgpio = self._load_lgpio() - tx_gpio = self.config.tx_gpio - h = lgpio.gpiochip_open(self.lgpio_chip) - lgpio.gpio_claim_output(h, tx_gpio) - - pulses = [] - pulses.append(lgpio.pulse(1, 1, 9415)) # wake up pulse - pulses.append(lgpio.pulse(0, 1, 89565)) # silence - for i in range(2): # hardware synchronization - pulses.append(lgpio.pulse(1, 1, 2560)) - pulses.append(lgpio.pulse(0, 1, 2560)) - pulses.append(lgpio.pulse(1, 1, 4550)) # software synchronization - pulses.append(lgpio.pulse(0, 1, 640)) - - for i in range(0, 56): # manchester encoding of payload data - if ((frame[int(i / 8)] >> (7 - (i % 8))) & 1): - pulses.append(lgpio.pulse(0, 1, 640)) - pulses.append(lgpio.pulse(1, 1, 640)) - else: - pulses.append(lgpio.pulse(1, 1, 640)) - pulses.append(lgpio.pulse(0, 1, 640)) - - pulses.append(lgpio.pulse(0, 1, 30415)) # interframe gap - - for j in range(1, repetition): # repeating frames - for i in range(7): # hardware synchronization - pulses.append(lgpio.pulse(1, 1, 2560)) - pulses.append(lgpio.pulse(0, 1, 2560)) - pulses.append(lgpio.pulse(1, 1, 4550)) # software synchronization - pulses.append(lgpio.pulse(0, 1, 640)) - - for i in range(0, 56): # manchester encoding of payload data - if ((frame[int(i / 8)] >> (7 - (i % 8))) & 1): - pulses.append(lgpio.pulse(0, 1, 640)) - pulses.append(lgpio.pulse(1, 1, 640)) - else: - pulses.append(lgpio.pulse(1, 1, 640)) - pulses.append(lgpio.pulse(0, 1, 640)) - - pulses.append(lgpio.pulse(0, 1, 30415)) # interframe gap - - lgpio.tx_wave(h, tx_gpio, pulses) - while lgpio.tx_busy(h, tx_gpio, lgpio.TX_WAVE): - time.sleep(0.001) - - lgpio.gpio_free(h, tx_gpio) - lgpio.gpiochip_close(h) +GPIOConfig = Raw433Config +GPIOTransmitter = Raw433Transmitter diff --git a/operateShutters.py b/operateShutters.py index 54d08d3..e7e05d4 100755 --- a/operateShutters.py +++ b/operateShutters.py @@ -139,7 +139,7 @@ def __init__(self, log = None, config = None, rf_transmitter = None, cc1101_modu 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", "gpio").strip().lower() + self.RFBackend = getattr(self.config, "RFBackend", "raw_433").strip().lower() self.rf_transmitter = rf_transmitter if self.rf_transmitter is None: try: diff --git a/raw_433_backend.py b/raw_433_backend.py new file mode 100644 index 0000000..a2736d5 --- /dev/null +++ b/raw_433_backend.py @@ -0,0 +1,161 @@ +#!/usr/bin/python3 + +import time + + +class Raw433Config: + DEFAULT_TXGPIO = 4 + + def __init__(self, tx_gpio=DEFAULT_TXGPIO): + self.tx_gpio = int(tx_gpio) + + @classmethod + def from_app_config(cls, config): + if hasattr(config, "ReadValue"): + return cls( + tx_gpio=config.ReadValue( + "TXGPIO", + return_type=int, + default=cls.DEFAULT_TXGPIO, + section="General", + ) + ) + return cls(tx_gpio=getattr(config, "TXGPIO", cls.DEFAULT_TXGPIO)) + + +class Raw433Transmitter: + def __init__( + self, + config, + is_pi5=False, + pigpio_module=None, + lgpio_module=None, + lgpio_chip=4, + ): + self.config = config + self.is_pi5 = is_pi5 + self.pigpio = pigpio_module + self.lgpio = lgpio_module + self.lgpio_chip = lgpio_chip + + def transmit(self, frame, repetition): + if self.is_pi5: + self._send_lgpio(frame, repetition) + else: + self._send_pigpio(frame, repetition) + + def _load_pigpio(self): + if self.pigpio is not None: + return self.pigpio + import pigpio + return pigpio + + def _load_lgpio(self): + if self.lgpio is not None: + return self.lgpio + import lgpio + return lgpio + + def _send_pigpio(self, frame, repetition): + pigpio = self._load_pigpio() + pi = pigpio.pi() + + if not pi.connected: + exit() + + tx_gpio = self.config.tx_gpio + pi.wave_add_new() + pi.set_mode(tx_gpio, pigpio.OUTPUT) + + wf = [] + wf.append(pigpio.pulse(1 << tx_gpio, 0, 9415)) # wake up pulse + wf.append(pigpio.pulse(0, 1 << tx_gpio, 89565)) # silence + for i in range(2): # hardware synchronization + wf.append(pigpio.pulse(1 << tx_gpio, 0, 2560)) + wf.append(pigpio.pulse(0, 1 << tx_gpio, 2560)) + wf.append(pigpio.pulse(1 << tx_gpio, 0, 4550)) # software synchronization + wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) + + for i in range(0, 56): # manchester encoding of payload data + if ((frame[int(i / 8)] >> (7 - (i % 8))) & 1): + wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) + wf.append(pigpio.pulse(1 << tx_gpio, 0, 640)) + else: + wf.append(pigpio.pulse(1 << tx_gpio, 0, 640)) + wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) + + wf.append(pigpio.pulse(0, 1 << tx_gpio, 30415)) # interframe gap + + for j in range(1, repetition): # repeating frames + for i in range(7): # hardware synchronization + wf.append(pigpio.pulse(1 << tx_gpio, 0, 2560)) + wf.append(pigpio.pulse(0, 1 << tx_gpio, 2560)) + wf.append(pigpio.pulse(1 << tx_gpio, 0, 4550)) # software synchronization + wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) + + for i in range(0, 56): # manchester encoding of payload data + if ((frame[int(i / 8)] >> (7 - (i % 8))) & 1): + wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) + wf.append(pigpio.pulse(1 << tx_gpio, 0, 640)) + else: + wf.append(pigpio.pulse(1 << tx_gpio, 0, 640)) + wf.append(pigpio.pulse(0, 1 << tx_gpio, 640)) + + wf.append(pigpio.pulse(0, 1 << tx_gpio, 30415)) # interframe gap + + pi.wave_add_generic(wf) + wid = pi.wave_create() + pi.wave_send_once(wid) + while pi.wave_tx_busy(): + pass + pi.wave_delete(wid) + pi.stop() + + def _send_lgpio(self, frame, repetition): + lgpio = self._load_lgpio() + tx_gpio = self.config.tx_gpio + h = lgpio.gpiochip_open(self.lgpio_chip) + lgpio.gpio_claim_output(h, tx_gpio) + + pulses = [] + pulses.append(lgpio.pulse(1, 1, 9415)) # wake up pulse + pulses.append(lgpio.pulse(0, 1, 89565)) # silence + for i in range(2): # hardware synchronization + pulses.append(lgpio.pulse(1, 1, 2560)) + pulses.append(lgpio.pulse(0, 1, 2560)) + pulses.append(lgpio.pulse(1, 1, 4550)) # software synchronization + pulses.append(lgpio.pulse(0, 1, 640)) + + for i in range(0, 56): # manchester encoding of payload data + if ((frame[int(i / 8)] >> (7 - (i % 8))) & 1): + pulses.append(lgpio.pulse(0, 1, 640)) + pulses.append(lgpio.pulse(1, 1, 640)) + else: + pulses.append(lgpio.pulse(1, 1, 640)) + pulses.append(lgpio.pulse(0, 1, 640)) + + pulses.append(lgpio.pulse(0, 1, 30415)) # interframe gap + + for j in range(1, repetition): # repeating frames + for i in range(7): # hardware synchronization + pulses.append(lgpio.pulse(1, 1, 2560)) + pulses.append(lgpio.pulse(0, 1, 2560)) + pulses.append(lgpio.pulse(1, 1, 4550)) # software synchronization + pulses.append(lgpio.pulse(0, 1, 640)) + + for i in range(0, 56): # manchester encoding of payload data + if ((frame[int(i / 8)] >> (7 - (i % 8))) & 1): + pulses.append(lgpio.pulse(0, 1, 640)) + pulses.append(lgpio.pulse(1, 1, 640)) + else: + pulses.append(lgpio.pulse(1, 1, 640)) + pulses.append(lgpio.pulse(0, 1, 640)) + + pulses.append(lgpio.pulse(0, 1, 30415)) # interframe gap + + lgpio.tx_wave(h, tx_gpio, pulses) + while lgpio.tx_busy(h, tx_gpio, lgpio.TX_WAVE): + time.sleep(0.001) + + lgpio.gpio_free(h, tx_gpio) + lgpio.gpiochip_close(h) diff --git a/rf_backend.py b/rf_backend.py index a50a6fa..9e7a6c9 100644 --- a/rf_backend.py +++ b/rf_backend.py @@ -2,12 +2,15 @@ from cc1101_backend import CC1101Config from cc1101_backend import CC1101Transmitter -from gpio_backend import GPIOConfig -from gpio_backend import GPIOTransmitter +from raw_433_backend import Raw433Config +from raw_433_backend import Raw433Transmitter def get_backend_name(config): - return getattr(config, "RFBackend", "gpio").strip().lower() + backend_name = getattr(config, "RFBackend", "raw_433").strip().lower() + if backend_name == "gpio": + return "raw_433" + return backend_name def create_transmitter( @@ -19,20 +22,20 @@ def create_transmitter( lgpio_chip=4, ): backend_name = get_backend_name(config) - gpio_transmitter = GPIOTransmitter( - GPIOConfig.from_app_config(config), + raw_433_transmitter = Raw433Transmitter( + Raw433Config.from_app_config(config), is_pi5=is_pi5, pigpio_module=pigpio_module, lgpio_module=lgpio_module, lgpio_chip=lgpio_chip, ) - if backend_name == "gpio": - return gpio_transmitter + if backend_name == "raw_433": + return raw_433_transmitter if backend_name == "cc1101": return CC1101Transmitter( CC1101Config.from_app_config(config), - gpio_transmitter, + raw_433_transmitter, cc1101_module=cc1101_module, ) raise ValueError("Unsupported RFBackend: " + str(backend_name)) diff --git a/tests/test_gpio_backend.py b/tests/test_raw_433_backend.py similarity index 91% rename from tests/test_gpio_backend.py rename to tests/test_raw_433_backend.py index e172913..c1b0a44 100644 --- a/tests/test_gpio_backend.py +++ b/tests/test_raw_433_backend.py @@ -1,6 +1,6 @@ import unittest -from gpio_backend import GPIOConfig, GPIOTransmitter +from raw_433_backend import Raw433Config, Raw433Transmitter class FakePi: @@ -78,7 +78,7 @@ def gpiochip_close(self, handle): self.calls.append(("gpiochip_close", handle)) -class GPIOBackendTest(unittest.TestCase): +class Raw433BackendTest(unittest.TestCase): def setUp(self): FakePi.instances = [] @@ -89,13 +89,13 @@ def ReadValue(self, entry, return_type=str, default=None, section=None): return 17 return default - config = GPIOConfig.from_app_config(AppConfig()) + config = Raw433Config.from_app_config(AppConfig()) self.assertEqual(17, config.tx_gpio) def test_pigpio_transmitter_sends_waveform(self): - transmitter = GPIOTransmitter( - GPIOConfig(tx_gpio=4), + transmitter = Raw433Transmitter( + Raw433Config(tx_gpio=4), is_pi5=False, pigpio_module=FakePigpio(), ) @@ -113,8 +113,8 @@ def test_pigpio_transmitter_sends_waveform(self): def test_lgpio_transmitter_sends_waveform(self): fake_lgpio = FakeLgpio() - transmitter = GPIOTransmitter( - GPIOConfig(tx_gpio=4), + transmitter = Raw433Transmitter( + Raw433Config(tx_gpio=4), is_pi5=True, lgpio_module=fake_lgpio, lgpio_chip=4, diff --git a/tests/test_rf_backend.py b/tests/test_rf_backend.py index 09ac515..0e76779 100644 --- a/tests/test_rf_backend.py +++ b/tests/test_rf_backend.py @@ -36,7 +36,7 @@ def _install_import_stubs(): class FakeConfig: TXGPIO = 4 - RFBackend = "gpio" + RFBackend = "raw_433" CC1101Frequency = 433.42 CC1101SPIBus = 0 CC1101SPIDevice = 0 @@ -87,9 +87,9 @@ class BackendDispatchTest(unittest.TestCase): def setUp(self): FakeRadio.instances = [] - def test_gpio_backend_uses_existing_waveform_path(self): + def test_raw_433_backend_uses_existing_waveform_path(self): config = FakeConfig() - config.RFBackend = "gpio" + config.RFBackend = "raw_433" transmitter = mock.Mock() shutter = operateShutters.Shutter(config=config, rf_transmitter=transmitter) diff --git a/tests/test_rf_backend_factory.py b/tests/test_rf_backend_factory.py index f5baa38..a98d44c 100644 --- a/tests/test_rf_backend_factory.py +++ b/tests/test_rf_backend_factory.py @@ -2,8 +2,9 @@ import unittest from cc1101_backend import CC1101Transmitter -from gpio_backend import GPIOTransmitter +from raw_433_backend import Raw433Transmitter from rf_backend import create_transmitter +from rf_backend import get_backend_name class FakeRadio: @@ -16,7 +17,18 @@ class FakePigpio: class BackendFactoryTest(unittest.TestCase): - def test_creates_gpio_transmitter_by_default(self): + def test_creates_raw_433_transmitter_by_default(self): + config = types.SimpleNamespace(RFBackend="raw_433", TXGPIO=4) + + transmitter = create_transmitter( + config, + is_pi5=False, + pigpio_module=FakePigpio(), + ) + + self.assertIsInstance(transmitter, Raw433Transmitter) + + def test_accepts_legacy_gpio_backend_alias(self): config = types.SimpleNamespace(RFBackend="gpio", TXGPIO=4) transmitter = create_transmitter( @@ -25,9 +37,10 @@ def test_creates_gpio_transmitter_by_default(self): pigpio_module=FakePigpio(), ) - self.assertIsInstance(transmitter, GPIOTransmitter) + self.assertEqual("raw_433", get_backend_name(config)) + self.assertIsInstance(transmitter, Raw433Transmitter) - def test_creates_cc1101_transmitter_wrapping_gpio_transmitter(self): + def test_creates_cc1101_transmitter_wrapping_raw_433_transmitter(self): config = types.SimpleNamespace( RFBackend="cc1101", TXGPIO=4, @@ -45,7 +58,7 @@ def test_creates_cc1101_transmitter_wrapping_gpio_transmitter(self): ) self.assertIsInstance(transmitter, CC1101Transmitter) - self.assertIsInstance(transmitter.waveform_transmitter, GPIOTransmitter) + self.assertIsInstance(transmitter.waveform_transmitter, Raw433Transmitter) def test_rejects_unknown_backend(self): config = types.SimpleNamespace(RFBackend="other", TXGPIO=4) diff --git a/tests/test_rf_config.py b/tests/test_rf_config.py index 081605b..a03b237 100644 --- a/tests/test_rf_config.py +++ b/tests/test_rf_config.py @@ -19,7 +19,7 @@ def _load_config(self, content): finally: os.unlink(path) - def test_defaults_to_gpio_backend_for_existing_configs(self): + def test_defaults_to_raw_433_backend_for_existing_configs(self): config = self._load_config( """ [General] @@ -41,7 +41,7 @@ def test_defaults_to_gpio_backend_for_existing_configs(self): """ ) - self.assertEqual("gpio", config.RFBackend) + self.assertEqual("raw_433", config.RFBackend) self.assertFalse(hasattr(config, "CC1101Frequency")) self.assertFalse(hasattr(config, "CC1101SPIBus")) self.assertFalse(hasattr(config, "CC1101SPIDevice")) From eada87b8ae21aa4e951f0fd9d9fa295adcde300d Mon Sep 17 00:00:00 2001 From: Idan Freiberg Date: Fri, 5 Jun 2026 06:59:29 +0300 Subject: [PATCH 06/13] refactor: require cc1101 backend dependency --- cc1101_backend.py | 19 ++++----------- .../plans/2026-06-05-e07-m1101d-sma-cc1101.md | 2 +- gpio_backend.py | 8 ------- operateShutters.py | 3 +-- rf_backend.py | 2 -- tests/test_cc1101_backend.py | 24 +++++++++++++++---- tests/test_rf_backend.py | 21 +++++++++++----- tests/test_rf_backend_factory.py | 17 ++++++++++++- 8 files changed, 58 insertions(+), 38 deletions(-) delete mode 100644 gpio_backend.py diff --git a/cc1101_backend.py b/cc1101_backend.py index d51388e..ae836a2 100644 --- a/cc1101_backend.py +++ b/cc1101_backend.py @@ -1,5 +1,8 @@ #!/usr/bin/python3 +import cc1101 + + class CC1101Config: DEFAULT_FREQUENCY_MHZ = 433.42 DEFAULT_SPI_BUS = 0 @@ -66,27 +69,15 @@ def output_power_table(self): class CC1101Transmitter: - def __init__(self, config, waveform_transmitter, cc1101_module=None): + def __init__(self, config, waveform_transmitter): self.config = config self.waveform_transmitter = waveform_transmitter - cc1101_module = self._load_cc1101_module(cc1101_module) - self.radio = cc1101_module.CC1101( + self.radio = cc1101.CC1101( spi_bus=self.config.spi_bus, spi_chip_select=self.config.spi_device, lock_spi_device=True, ) - def _load_cc1101_module(self, cc1101_module): - if cc1101_module is not None: - return cc1101_module - try: - import cc1101 - return cc1101 - except ImportError as e: - raise RuntimeError( - "RFBackend=cc1101 requires the cc1101 Python package: " + str(e) - ) - def transmit(self, frame, repetition): with self.radio as radio: radio.set_base_frequency_hertz(self.config.frequency_hz) diff --git a/docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md b/docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md index 2b592fe..9615a96 100644 --- a/docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md +++ b/docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md @@ -6,7 +6,7 @@ **Architecture:** Keep Somfy RTS frame generation unchanged. Split transmission so `sendCommand()` builds the frame once, then dispatches to either the existing GPIO waveform path or a CC1101 wrapper that configures the radio over SPI and reuses the same waveform on GDO0. -**Tech Stack:** Python 3, `unittest`, `pigpio`/`lgpio`, optional `cc1101` plus `spidev` for the E07 module. +**Tech Stack:** Python 3, `unittest`, `pigpio`/`lgpio`, `cc1101` plus `spidev` for the E07 module. --- diff --git a/gpio_backend.py b/gpio_backend.py deleted file mode 100644 index 22c752e..0000000 --- a/gpio_backend.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/python3 - -from raw_433_backend import Raw433Config -from raw_433_backend import Raw433Transmitter - - -GPIOConfig = Raw433Config -GPIOTransmitter = Raw433Transmitter diff --git a/operateShutters.py b/operateShutters.py index e7e05d4..ea47a67 100755 --- a/operateShutters.py +++ b/operateShutters.py @@ -127,7 +127,7 @@ def registerCommand(self, commandDirection): self.lastCommandDirection = commandDirection self.lastCommandTime = time.monotonic() - def __init__(self, log = None, config = None, rf_transmitter = None, cc1101_module = None): + def __init__(self, log = None, config = None, rf_transmitter = None): super(Shutter, self).__init__() self.lock = threading.Lock() if log is not None: @@ -148,7 +148,6 @@ def __init__(self, log = None, config = None, rf_transmitter = None, cc1101_modu is_pi5=IS_PI5, pigpio_module=globals().get("pigpio"), lgpio_module=globals().get("lgpio"), - cc1101_module=cc1101_module, lgpio_chip=LGPIO_CHIP, ) except (RuntimeError, ValueError) as e: diff --git a/rf_backend.py b/rf_backend.py index 9e7a6c9..5bfddf8 100644 --- a/rf_backend.py +++ b/rf_backend.py @@ -18,7 +18,6 @@ def create_transmitter( is_pi5=False, pigpio_module=None, lgpio_module=None, - cc1101_module=None, lgpio_chip=4, ): backend_name = get_backend_name(config) @@ -36,6 +35,5 @@ def create_transmitter( return CC1101Transmitter( CC1101Config.from_app_config(config), raw_433_transmitter, - cc1101_module=cc1101_module, ) raise ValueError("Unsupported RFBackend: " + str(backend_name)) diff --git a/tests/test_cc1101_backend.py b/tests/test_cc1101_backend.py index 7020411..9ef6511 100644 --- a/tests/test_cc1101_backend.py +++ b/tests/test_cc1101_backend.py @@ -1,7 +1,13 @@ import contextlib +import inspect +import sys import types import unittest +fake_cc1101 = types.ModuleType("cc1101") +sys.modules["cc1101"] = fake_cc1101 + +import cc1101_backend from cc1101_backend import CC1101Config, CC1101Transmitter @@ -42,6 +48,18 @@ def asynchronous_transmission(self): class CC1101BackendTest(unittest.TestCase): def setUp(self): FakeRadio.instances = [] + fake_cc1101.CC1101 = FakeRadio + if hasattr(cc1101_backend, "cc1101"): + cc1101_backend.cc1101.CC1101 = FakeRadio + + def test_imports_cc1101_at_module_load_time(self): + self.assertIs(fake_cc1101, cc1101_backend.cc1101) + + def test_transmitter_uses_imported_cc1101_module(self): + self.assertNotIn( + "cc1101_module", + inspect.signature(CC1101Transmitter).parameters, + ) def test_config_is_derived_from_app_config(self): app_config = types.SimpleNamespace( @@ -85,7 +103,6 @@ def ReadValue(self, entry, return_type=str, default=None, section=None): self.assertEqual(0xC6, config.output_power) def test_transmitter_configures_radio_and_calls_waveform_callback(self): - fake_cc1101 = types.SimpleNamespace(CC1101=FakeRadio) config = CC1101Config( frequency_mhz=433.42, spi_bus=0, @@ -96,7 +113,7 @@ def test_transmitter_configures_radio_and_calls_waveform_callback(self): frame = bytearray([0] * 7) calls = [] - transmitter = CC1101Transmitter(config, waveform_transmitter, cc1101_module=fake_cc1101) + transmitter = CC1101Transmitter(config, waveform_transmitter) transmitter.transmit(frame, 3) self.assertEqual([("waveform", frame, 3)], calls) @@ -119,13 +136,12 @@ def test_transmitter_configures_radio_and_calls_waveform_callback(self): ) def test_transmitter_reuses_radio_object_between_transmits(self): - fake_cc1101 = types.SimpleNamespace(CC1101=FakeRadio) config = CC1101Config() waveform_transmitter = types.SimpleNamespace(transmit=lambda frame, repetition: calls.append(("waveform", repetition))) frame = bytearray([0] * 7) calls = [] - transmitter = CC1101Transmitter(config, waveform_transmitter, cc1101_module=fake_cc1101) + transmitter = CC1101Transmitter(config, waveform_transmitter) transmitter.transmit(frame, 1) transmitter.transmit(frame, 2) diff --git a/tests/test_rf_backend.py b/tests/test_rf_backend.py index 0e76779..99f618c 100644 --- a/tests/test_rf_backend.py +++ b/tests/test_rf_backend.py @@ -1,4 +1,5 @@ import contextlib +import inspect import importlib import json import sys @@ -20,6 +21,9 @@ def _install_import_stubs(): lgpio.TX_WAVE = 1 sys.modules.setdefault("lgpio", lgpio) + cc1101 = types.ModuleType("cc1101") + sys.modules.setdefault("cc1101", cc1101) + flask = types.ModuleType("flask") flask.Flask = object flask.render_template = lambda *args, **kwargs: "" @@ -31,6 +35,7 @@ def _install_import_stubs(): _install_import_stubs() +cc1101_backend = importlib.import_module("cc1101_backend") operateShutters = importlib.import_module("operateShutters") @@ -86,6 +91,14 @@ def asynchronous_transmission(self): class BackendDispatchTest(unittest.TestCase): def setUp(self): FakeRadio.instances = [] + sys.modules["cc1101"].CC1101 = FakeRadio + cc1101_backend.cc1101.CC1101 = FakeRadio + + def test_shutter_uses_imported_cc1101_module(self): + self.assertNotIn( + "cc1101_module", + inspect.signature(operateShutters.Shutter).parameters, + ) def test_raw_433_backend_uses_existing_waveform_path(self): config = FakeConfig() @@ -105,8 +118,7 @@ def test_cc1101_backend_configures_radio_and_reuses_waveform(self): config.CC1101SPIBus = 0 config.CC1101SPIDevice = 0 config.CC1101OutputPower = 0xC6 - fake_cc1101 = types.SimpleNamespace(CC1101=FakeRadio) - shutter = operateShutters.Shutter(config=config, cc1101_module=fake_cc1101) + shutter = operateShutters.Shutter(config=config) shutter.rf_transmitter.waveform_transmitter.transmit = mock.Mock() shutter.sendCommand("279620", shutter.buttonDown, 3) @@ -126,10 +138,7 @@ def test_cc1101_backend_configures_radio_and_reuses_waveform(self): def test_cc1101_backend_reuses_transmitter_between_commands(self): config = FakeConfig() config.RFBackend = "cc1101" - shutter = operateShutters.Shutter( - config=config, - cc1101_module=types.SimpleNamespace(CC1101=FakeRadio), - ) + shutter = operateShutters.Shutter(config=config) shutter.rf_transmitter.waveform_transmitter.transmit = mock.Mock() shutter.sendCommand("279620", shutter.buttonUp, 1) diff --git a/tests/test_rf_backend_factory.py b/tests/test_rf_backend_factory.py index a98d44c..c07bceb 100644 --- a/tests/test_rf_backend_factory.py +++ b/tests/test_rf_backend_factory.py @@ -1,7 +1,13 @@ +import inspect +import sys import types import unittest +fake_cc1101 = types.ModuleType("cc1101") +sys.modules["cc1101"] = fake_cc1101 + from cc1101_backend import CC1101Transmitter +import cc1101_backend from raw_433_backend import Raw433Transmitter from rf_backend import create_transmitter from rf_backend import get_backend_name @@ -17,6 +23,16 @@ class FakePigpio: class BackendFactoryTest(unittest.TestCase): + def setUp(self): + fake_cc1101.CC1101 = FakeRadio + cc1101_backend.cc1101.CC1101 = FakeRadio + + def test_factory_uses_imported_cc1101_module(self): + self.assertNotIn( + "cc1101_module", + inspect.signature(create_transmitter).parameters, + ) + def test_creates_raw_433_transmitter_by_default(self): config = types.SimpleNamespace(RFBackend="raw_433", TXGPIO=4) @@ -54,7 +70,6 @@ def test_creates_cc1101_transmitter_wrapping_raw_433_transmitter(self): config, is_pi5=False, pigpio_module=FakePigpio(), - cc1101_module=types.SimpleNamespace(CC1101=FakeRadio), ) self.assertIsInstance(transmitter, CC1101Transmitter) From 1a5df53ab986ec1ab26e8f4f33799db64ade3730 Mon Sep 17 00:00:00 2001 From: Idan Freiberg Date: Fri, 5 Jun 2026 07:04:17 +0300 Subject: [PATCH 07/13] docs: stop advertising gpio rf backend alias --- README.md | 3 --- defaultConfig.conf | 1 - 2 files changed, 4 deletions(-) diff --git a/README.md b/README.md index d838041..160a909 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,6 @@ TXGPIO = 4 RFBackend = raw_433 ``` -The older `RFBackend = gpio` value is still accepted as an alias for `raw_433`. - - ![Full Picture](documentation/Full%20Assembly.jpg)
![Pi Connection](documentation/Connection.jpg)
![RF Transmitter Connection](documentation/Sender.jpg)
diff --git a/defaultConfig.conf b/defaultConfig.conf index 3a32673..508aba7 100644 --- a/defaultConfig.conf +++ b/defaultConfig.conf @@ -28,7 +28,6 @@ TXGPIO = 4 # (Optional) RF backend used for transmission. Supported values: # raw_433 - raw 433.42 MHz ASK/OOK transmitter driven from TXGPIO # cc1101 - CC1101 modules such as the E07-M1101D-SMA, with GDO0 wired to TXGPIO -# Legacy value "gpio" is accepted as an alias for raw_433. RFBackend = raw_433 # (Optional) CC1101 backend settings used only when RFBackend = cc1101. From 095de3296a299e6a1f3775454e159ed81afd7441 Mon Sep 17 00:00:00 2001 From: Idan Freiberg Date: Fri, 5 Jun 2026 07:07:04 +0300 Subject: [PATCH 08/13] docs: separate rf hardware options --- README.md | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 160a909..eaf3095 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,15 @@ This project has been developed and tested with a Raspberry Pi B+ and a Raspberr Wi-Fi connectivity and Ethernet cable should both work. Note that the hardware has to be reasonably close (i.e. in the same house or in the same aisle of your mansion: just like a physical remote) to the shutters you operate, as the signal strength will otherwise not be sufficient. -As of now, you have to build your own hardware. Here are the steps to do so. +Pi-Somfy supports two RF hardware options: + +1. A raw 433 MHz ASK/OOK transmitter module. This is the original low-cost hardware path. +1. An E07-M1101D-SMA CC1101 module. This is controlled over SPI and transmits the same Somfy RTS waveform through the CC1101 radio. + +### Raw 433 MHz transmitter + +For the original raw 433 MHz transmitter, you have to build your own hardware. Here are the steps to do so. + 1. You need the RF Transmitter. If you wish to order it from eBay, this link may be helpful:
[Order](https://www.ebay.com/sch/sis.html?_nkw=5x+433Mhz+RF+transmitter+and+receiver+kit+Module+Arduino+ARM+WL+MCU+Raspberry).
Note that desoldering a 3 pin component isn't trivial, so ordering more than one may be a good idea in case of a screw up. 1. You need an oscillator for a 433.42 MHz frequency. The above RF transmitter comes with a common 433.93 MHz one, which will not work with your Somfy shutter. If you wish to order it from eBay, this link may be helpful:
[Order](https://www.ebay.com/sch/sis.html?_nkw=433.42M+R433+F433+SAW+Resonator+Crystals+TO-39) 1. You will need cables to connect the transmitter to the Raspberry Pi. Any cable will do obviously, but I found these quite helpful.
[Order](https://www.ebay.com/itm/40Pin-Multicolored-Dupont-Wire-Kits-Breadboard-Female-Jumper-Ribbon-Cable/113310899442) @@ -33,7 +41,20 @@ Note that I used GPIO 4 but you can change the value of __TXGPIO__ to whatever y OK. now this all should look like this. Note that some of the pictures are a bit confusing with regards to which GPIO a cable connects to. It's easier to see on the above diagram. But if you struggle, maybe the [Wiring Diagram](documentation/Wiring%20Diagram.txt) helps. -### E07-M1101D-SMA / CC1101 option +Set the top-level RF backend in `operateShutters.conf`: + +```ini +TXGPIO = 4 +RFBackend = raw_433 +``` + +Raw 433 MHz transmitter connection photos: + +![Full Picture](documentation/Full%20Assembly.jpg)
+![Pi Connection](documentation/Connection.jpg)
+![RF Transmitter Connection](documentation/Sender.jpg)
+ +### E07-M1101D-SMA / CC1101 module Pi-Somfy can also drive an E07-M1101D-SMA CC1101 module. This module is controlled over SPI and uses the CC1101 asynchronous transmit mode, with the Somfy RTS waveform driven into GDO0. @@ -61,16 +82,11 @@ CC1101SPIDevice = 0 CC1101OutputPower = 0xC6 ``` -For the original raw 433 MHz ASK/OOK transmitter module, use: +CC1101 connection photos to add: -```ini -TXGPIO = 4 -RFBackend = raw_433 -``` - -![Full Picture](documentation/Full%20Assembly.jpg)
-![Pi Connection](documentation/Connection.jpg)
-![RF Transmitter Connection](documentation/Sender.jpg)
+- `documentation/CC1101 Full Assembly.jpg` +- `documentation/CC1101 Pi Connection.jpg` +- `documentation/CC1101 Module Connection.jpg` ## 3 Software From 8ed4b19dec144d40152fd0fad2362043406aebdf Mon Sep 17 00:00:00 2001 From: Idan Freiberg Date: Fri, 5 Jun 2026 07:26:59 +0300 Subject: [PATCH 09/13] docs: capture raspberry pi deployment requirements --- README.md | 94 ++++++++++++++++++++++------------- installService.sh | 52 +++++++++++++++++-- requirements.txt | 3 ++ shutters.service | 14 +++--- start.sh | 18 ++++++- tests/test_service_scripts.py | 32 ++++++++++++ 6 files changed, 167 insertions(+), 46 deletions(-) create mode 100644 tests/test_service_scripts.py diff --git a/README.md b/README.md index eaf3095..3a4a133 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ If you are not familiar with remote login commands for Linux/Unix, two useful co The Raspberry Pi organization has documentation on installing an operating system on your Raspberry Pi. It is located [here](https://www.raspberrypi.org/documentation/installation/installing-images/README.md). -Once the Pi has its basic setup (an operating system and an internet connection) working, ssh into your Raspberry Pi and you should find that you are in the directory /home/pi. Note: if you prefer not to use a headless system, you can also open a terminal windows directly on the Pi. +Once the Pi has its basic setup (an operating system and an internet connection) working, ssh into your Raspberry Pi. The examples below install into `~/Pi-Somfy`; if you use a different directory, run the commands from that checkout. The next step is to download the Pi-Somfy project files to your Raspberry Pi. The easiest way to do this is to use the "git" program. Most Raspberry Pi distributions include the git program (except Debian Lite). @@ -108,51 +108,71 @@ sudo apt-get install git ``` (If git isn't installed, it will install it; if it was previously, it will update it) -Once git is installed on your system, make sure you are in the /home/pi directory, then type: +Once git is installed on your system, clone the project: ```sh +cd ~ git clone https://github.com/Nickduino/Pi-Somfy.git +cd Pi-Somfy ``` -The above command will make a directory in /home/pi named Pi-Somfy and put the project files in this directory. +The above command will make a directory named `Pi-Somfy` and put the project files in this directory. -Next, we need to install Python Libraries. Pi-Somfy requires Python 3. Ensure pip3 is installed: +Next, install the Raspberry Pi OS packages used by Pi-Somfy. On current Raspberry Pi OS releases, the GPIO package is `python3-pigpio`; the bare `pigpio` package name may not be installable. ```sh sudo apt-get update -sudo apt-get install python3-pip +sudo apt-get install python3-venv python3-pip python3-pigpio python3-lgpio python3-spidev ``` -Next, we need to install the PIGPIO libraries, to do so, type: +For the E07-M1101D-SMA / CC1101 backend, enable SPI: ```sh -sudo apt-get install pigpio +sudo raspi-config nonint do_spi 0 +ls -l /dev/spidev* ``` -For the E07-M1101D-SMA / CC1101 backend, also enable SPI and install the spidev package: +If `/dev/spidev0.0` and `/dev/spidev0.1` do not appear, reboot and check again. + +Create a Python virtual environment that can see the Raspberry Pi OS GPIO/SPI packages, then install the Python requirements: ```sh -sudo raspi-config -sudo apt-get install python3-spidev +python3 -m venv --system-site-packages .venv +.venv/bin/python -m pip install --upgrade pip setuptools wheel +.venv/bin/python -m pip install -r requirements.txt ``` -Next install the required Python Libraries: +The `--system-site-packages` option is intentional: Raspberry Pi OS supplies `pigpio`, `lgpio`, and `spidev` as apt packages, while `requirements.txt` installs or verifies the Python packages used by the app, including `cc1101`. + +If this is a new install, create the config from the default and choose the RF backend: ```sh -sudo pip3 install -r requirements.txt +cp defaultConfig.conf operateShutters.conf +``` + +For the raw 433 MHz transmitter, keep: + +```ini +RFBackend = raw_433 +``` + +For the E07-M1101D-SMA / CC1101 module, set: + +```ini +RFBackend = cc1101 ``` -Next, let's test if it all works. Start `operateShutters.py` by typing: +Next, test the Python environment by typing: ```sh -sudo python3 /home/pi/Pi-Somfy/operateShutters.py +.venv/bin/python operateShutters.py -h ``` You should see the help text explaining the [Command Line Interface](documentation/p4.png) ## 4 Usage -Note that the config file won't exist the first time you run the application. In that case, a new config file will be created based on the name you specified (e.g. /home/pi/Pi-Somfy/operateShutters.conf). Once it has been created, you can modify it to change your need (SSL or not, which port is used, etc.), it will not be erased with an update. If you messed up something, just delete it and relaunch operateShutters.py, a new vanilla copy will be generated. +Note that the config file won't exist the first time you run the application unless you copied it from `defaultConfig.conf` during install. In that case, a new config file will be created based on the name you specified (e.g. `operateShutters.conf` in your checkout). Once it has been created, you can modify it to change your need (SSL or not, which port is used, etc.), it will not be erased with an update. If you messed up something, just delete it and relaunch operateShutters.py, a new vanilla copy will be generated. You have 6 ways to operate. The recommended operation mode is mode 5. But the other 5 modes are explained here for completeness: @@ -185,46 +205,52 @@ You have 6 ways to operate. The recommended operation mode is mode 5. But the ot **Examples:** All three command the shutter named corridor. The first one will raise it. The second one will lower it. The third one will lower the shutter at sunset and raise it again 60 minutes after sunrise. ```sh -sudo /home/pi/Pi-Somfy/operateShutters.py corridor -c /home/pi/Pi-Somfy/operateShutters.conf -u -sudo /home/pi/Pi-Somfy/operateShutters.py corridor -c /home/pi/Pi-Somfy/operateShutters.conf -d -sudo /home/pi/Pi-Somfy/operateShutters.py corridor -c /home/pi/Pi-Somfy/operateShutters.conf -dd 0 60 +.venv/bin/python operateShutters.py corridor -c operateShutters.conf -u +.venv/bin/python operateShutters.py corridor -c operateShutters.conf -d +.venv/bin/python operateShutters.py corridor -c operateShutters.conf -dd 0 60 ``` 2. Manually start Web interface only
You can start the web-interface by typing:
Once started, you can access the web interface at http://IPaddressOfYourPi:80. From there you can further modify your settings. ```sh -sudo python3 /home/pi/Pi-Somfy/operateShutters.py -c /home/pi/Pi-Somfy/operateShutters.conf -a +sudo .venv/bin/python operateShutters.py -c operateShutters.conf -a ``` 3. Manually start Web interface and Alexa interface
You can start the web-interface by typing: ```sh -sudo python3 /home/pi/Pi-Somfy/operateShutters.py -c /home/pi/Pi-Somfy/operateShutters.conf -a -e +sudo .venv/bin/python operateShutters.py -c operateShutters.conf -a -e ``` 4. Manually start Web interface and MQTT integration (for Home Assistant)
You can start the web-interface by typing: ```sh -sudo python3 /home/pi/Pi-Somfy/operateShutters.py -c /home/pi/Pi-Somfy/operateShutters.conf -a -m +sudo .venv/bin/python operateShutters.py -c operateShutters.conf -a -m ``` 5. Finally, the recommended way to operate it is using a systemd service on boot time. You can do so by typing: ```sh -sudo bash /home/pi/Pi-Somfy/installService.sh +sudo bash ./installService.sh ``` -The service will be installed as a system service right after establishing network connectivity. +The service will be installed as `pi-somfy.service` and starts after network connectivity. +By default, the service runs the web interface with `-a`. To also enable MQTT and Alexa, install it like this: + +```sh +PI_SOMFY_ARGS="-a -m -e" sudo -E bash ./installService.sh +``` + If you want to stop the service simply type: ```sh -sudo systemctl stop shutters.service +sudo systemctl stop pi-somfy.service ``` If you want to start the service simply type: ```sh -sudo systemctl start shutters.service +sudo systemctl start pi-somfy.service ``` If you want to restart the service simply type: ```sh -sudo systemctl restart shutters.service +sudo systemctl restart pi-somfy.service ``` -Note, currently the service expects python3 for starting up. +The installer writes the service file with the path to your current checkout and uses `.venv/bin/python` when that virtual environment exists. ``` -ExecStart=sudo /usr/bin/python3 /home/pi/Pi-Somfy/operateShutters.py -c /home/pi/Pi-Somfy/operateShutters.conf -a -e -m +ExecStart=/path/to/Pi-Somfy/.venv/bin/python /path/to/Pi-Somfy/operateShutters.py -c /path/to/Pi-Somfy/operateShutters.conf -a ``` 6. Alternatively, you can use cron to run the program at boot time. You can do so by typing: @@ -234,8 +260,8 @@ sudo crontab -e Note, that "crontab -e" will just open a console-based text editor that you can edit the crontab script. The first time you run "crontab -e" you will be prompted to choose the editor. I recommend nano. From the crontab window, add the following to the bottom of the crontab script ``` -@reboot sleep 60;python3 /home/pi/Pi-Somfy/operateShutters.py -c /home/pi/Pi-Somfy/operateShutters.conf -a -e -m -0 * * * * python3 /home/pi/Pi-Somfy/operateShutters.py -c /home/pi/Pi-Somfy/operateShutters.conf -a -e -m +@reboot sleep 60; cd /path/to/Pi-Somfy && .venv/bin/python operateShutters.py -c operateShutters.conf -a -e -m +0 * * * * cd /path/to/Pi-Somfy && .venv/bin/python operateShutters.py -c operateShutters.conf -a -e -m ``` And save the crontab schedule. (if using nano type press ctrl-o to save the file, ctrl-x to exit nano). Now, every time your system is booted operateShutters will start. @@ -341,7 +367,7 @@ If you choose not to use the Home Assistant add-in, you can download the [Mosqui Second, start `operateShutters.py` with the "-m" option. This should look similar to this: ```sh -operateShutters.py -c /home/pi/Pi-Somfy/operateShutters.conf -a -m +.venv/bin/python operateShutters.py -c operateShutters.conf -a -m ``` And that's it, you are all set. @@ -412,10 +438,10 @@ The project has been updated to use current software libraries and fix a number If you already have Pi-Somfy installed, follow these steps to upgrade: ```sh -cd /home/pi/Pi-Somfy +cd ~/Pi-Somfy git pull -sudo pip3 install -r requirements.txt -sudo systemctl restart shutters.service +.venv/bin/python -m pip install -r requirements.txt +sudo systemctl restart pi-somfy.service ``` Note: if you are not using MQTT (`-m` flag), `paho-mqtt` is no longer required and you can skip installing it. Your existing `operateShutters.conf` will be preserved — the upgrade only replaces code files. diff --git a/installService.sh b/installService.sh index bc755d2..b996aeb 100644 --- a/installService.sh +++ b/installService.sh @@ -1,8 +1,50 @@ #!/bin/bash +set -euo pipefail -cd /home/pi/Pi-Somfy/ -cp /home/pi/Pi-Somfy/shutters.service /etc/systemd/system/shutters.service +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_FILE="${PI_SOMFY_CONFIG:-$APP_DIR/operateShutters.conf}" +SERVICE_NAME="${PI_SOMFY_SERVICE_NAME:-pi-somfy}" +SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" +APP_ARGS="${PI_SOMFY_ARGS:--a}" -systemctl daemon-reload -systemctl enable shutters -systemctl start shutters +if [ -n "${PI_SOMFY_PYTHON:-}" ]; then + PYTHON_BIN="$PI_SOMFY_PYTHON" +elif [ -x "$APP_DIR/.venv/bin/python" ]; then + PYTHON_BIN="$APP_DIR/.venv/bin/python" +else + PYTHON_BIN="/usr/bin/python3" +fi + +if [ ! -f "$CONFIG_FILE" ]; then + cp "$APP_DIR/defaultConfig.conf" "$CONFIG_FILE" +fi + +if [ "$(id -u)" -eq 0 ]; then + SUDO=() +else + SUDO=(sudo) +fi + +# Default service unit: pi-somfy.service +"${SUDO[@]}" tee "$SERVICE_FILE" >/dev/null < Date: Fri, 5 Jun 2026 13:06:41 +0300 Subject: [PATCH 10/13] chore: remove implementation plan artifact --- .../plans/2026-06-05-e07-m1101d-sma-cc1101.md | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md diff --git a/docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md b/docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md deleted file mode 100644 index 9615a96..0000000 --- a/docs/superpowers/plans/2026-06-05-e07-m1101d-sma-cc1101.md +++ /dev/null @@ -1,47 +0,0 @@ -# E07-M1101D-SMA CC1101 Support Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a selectable CC1101/E07-M1101D-SMA RF backend while preserving the existing one-pin GPIO transmitter behavior. - -**Architecture:** Keep Somfy RTS frame generation unchanged. Split transmission so `sendCommand()` builds the frame once, then dispatches to either the existing GPIO waveform path or a CC1101 wrapper that configures the radio over SPI and reuses the same waveform on GDO0. - -**Tech Stack:** Python 3, `unittest`, `pigpio`/`lgpio`, `cc1101` plus `spidev` for the E07 module. - ---- - -### Task 1: Backend Config Tests - -**Files:** -- Create: `tests/test_rf_config.py` -- Modify: `config.py` - -- [ ] Write a `unittest` test that loads a temporary config containing `RFBackend = cc1101`, `TXGPIO = 4`, `CC1101Frequency = 433.42`, `CC1101SPIBus = 0`, `CC1101SPIDevice = 0`, and `CC1101OutputPower = 0xC6`, then asserts the parsed attributes. -- [ ] Run `python3 -m unittest tests.test_rf_config -v` and verify it fails because these config fields are not parsed yet. -- [ ] Add default values and parser entries in `MyConfig.LoadConfig`. -- [ ] Re-run the test and verify it passes. - -### Task 2: Backend Dispatch Tests - -**Files:** -- Create: `tests/test_rf_backend.py` -- Modify: `operateShutters.py` - -- [ ] Write a test that instantiates `Shutter` with `RFBackend = raw_433` and patches the raw 433 transmitter to assert it is called. -- [ ] Write a test that instantiates `Shutter` with `RFBackend = cc1101`, injects a fake CC1101 module, patches the raw 433 transmitter, and asserts the radio is configured and `asynchronous_transmission()` wraps the waveform call. -- [ ] Run `python3 -m unittest tests.test_rf_backend -v` and verify it fails because backend dispatch does not exist yet. -- [ ] Extract the existing pigpio and lgpio transmit paths into a raw 433 transmitter backend, then add a CC1101 backend that wraps that waveform path. -- [ ] Re-run backend tests and verify they pass. - -### Task 3: Dependencies And Docs - -**Files:** -- Modify: `requirements.txt` -- Modify: `defaultConfig.conf` -- Modify: `README.md` - -- [ ] Add `cc1101` and document that `python3-spidev` may be needed on Raspberry Pi OS. -- [ ] Add commented CC1101 config keys to `defaultConfig.conf`. -- [ ] Add an E07-M1101D-SMA wiring table and config snippet to `README.md`. -- [ ] Run the full test suite with `python3 -m unittest discover -v`. -- [ ] Run `python3 -m py_compile operateShutters.py config.py`. From 8fd94ab1525b6ea3d85b1fd6992b737bcf15c105 Mon Sep 17 00:00:00 2001 From: Idan Freiberg Date: Fri, 5 Jun 2026 13:08:50 +0300 Subject: [PATCH 11/13] refactor: remove gpio rf backend alias --- rf_backend.py | 9 ++++----- tests/test_rf_backend_factory.py | 13 +++---------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/rf_backend.py b/rf_backend.py index 5bfddf8..0357eaf 100644 --- a/rf_backend.py +++ b/rf_backend.py @@ -7,10 +7,7 @@ def get_backend_name(config): - backend_name = getattr(config, "RFBackend", "raw_433").strip().lower() - if backend_name == "gpio": - return "raw_433" - return backend_name + return getattr(config, "RFBackend", "raw_433").strip().lower() def create_transmitter( @@ -36,4 +33,6 @@ def create_transmitter( CC1101Config.from_app_config(config), raw_433_transmitter, ) - raise ValueError("Unsupported RFBackend: " + str(backend_name)) + raise ValueError( + "Unsupported RFBackend: " + str(backend_name) + ". Use raw_433 or cc1101." + ) diff --git a/tests/test_rf_backend_factory.py b/tests/test_rf_backend_factory.py index c07bceb..beb548b 100644 --- a/tests/test_rf_backend_factory.py +++ b/tests/test_rf_backend_factory.py @@ -10,7 +10,6 @@ import cc1101_backend from raw_433_backend import Raw433Transmitter from rf_backend import create_transmitter -from rf_backend import get_backend_name class FakeRadio: @@ -44,17 +43,11 @@ def test_creates_raw_433_transmitter_by_default(self): self.assertIsInstance(transmitter, Raw433Transmitter) - def test_accepts_legacy_gpio_backend_alias(self): + def test_rejects_gpio_backend_alias(self): config = types.SimpleNamespace(RFBackend="gpio", TXGPIO=4) - transmitter = create_transmitter( - config, - is_pi5=False, - pigpio_module=FakePigpio(), - ) - - self.assertEqual("raw_433", get_backend_name(config)) - self.assertIsInstance(transmitter, Raw433Transmitter) + with self.assertRaisesRegex(ValueError, "raw_433 or cc1101"): + create_transmitter(config, is_pi5=False, pigpio_module=FakePigpio()) def test_creates_cc1101_transmitter_wrapping_raw_433_transmitter(self): config = types.SimpleNamespace( From 5c493e2054a29b8c425558a14aadb62955788a22 Mon Sep 17 00:00:00 2001 From: Idan Freiberg Date: Sat, 6 Jun 2026 06:28:39 +0300 Subject: [PATCH 12/13] fix: stabilize cc1101 async transmit startup --- README.md | 6 ++++ cc1101_backend.py | 20 +++++++++++++ defaultConfig.conf | 1 + raw_433_backend.py | 32 +++++++++++++++++++- tests/test_cc1101_backend.py | 16 ++++++++-- tests/test_raw_433_backend.py | 56 +++++++++++++++++++++++++++++++++-- tests/test_rf_backend.py | 7 +++++ 7 files changed, 131 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3a4a133..2080af2 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,11 @@ CC1101Frequency = 433.42 CC1101SPIBus = 0 CC1101SPIDevice = 0 CC1101OutputPower = 0xC6 +CC1101TransmitSettleSeconds = 0.01 ``` +`CC1101TransmitSettleSeconds` keeps GDO0 low briefly after the CC1101 enters asynchronous TX mode before the Somfy waveform starts. The default `0.01` is intended to avoid clipping the first wake-up pulse on modules that need a short TX settle time. + CC1101 connection photos to add: - `documentation/CC1101 Full Assembly.jpg` @@ -160,6 +163,9 @@ For the E07-M1101D-SMA / CC1101 module, set: ```ini RFBackend = cc1101 +CC1101Frequency = 433.42 +CC1101OutputPower = 0xC6 +CC1101TransmitSettleSeconds = 0.01 ``` Next, test the Python environment by typing: diff --git a/cc1101_backend.py b/cc1101_backend.py index ae836a2..c2dcbf6 100644 --- a/cc1101_backend.py +++ b/cc1101_backend.py @@ -1,5 +1,7 @@ #!/usr/bin/python3 +import time + import cc1101 @@ -8,6 +10,7 @@ class CC1101Config: DEFAULT_SPI_BUS = 0 DEFAULT_SPI_DEVICE = 0 DEFAULT_OUTPUT_POWER = 0xC6 + DEFAULT_TRANSMIT_SETTLE_SECONDS = 0.01 SYMBOL_RATE_BAUD = 1562.5 def __init__( @@ -16,11 +19,13 @@ def __init__( spi_bus=DEFAULT_SPI_BUS, spi_device=DEFAULT_SPI_DEVICE, output_power=DEFAULT_OUTPUT_POWER, + transmit_settle_seconds=DEFAULT_TRANSMIT_SETTLE_SECONDS, ): self.frequency_mhz = float(frequency_mhz) self.spi_bus = int(spi_bus) self.spi_device = int(spi_device) self.output_power = int(output_power) + self.transmit_settle_seconds = float(transmit_settle_seconds) self.symbol_rate_baud = self.SYMBOL_RATE_BAUD @classmethod @@ -51,12 +56,23 @@ def from_app_config(cls, config): default=cls.DEFAULT_OUTPUT_POWER, section="General", ), + transmit_settle_seconds=config.ReadValue( + "CC1101TransmitSettleSeconds", + return_type=float, + default=cls.DEFAULT_TRANSMIT_SETTLE_SECONDS, + section="General", + ), ) return cls( frequency_mhz=getattr(config, "CC1101Frequency", cls.DEFAULT_FREQUENCY_MHZ), spi_bus=getattr(config, "CC1101SPIBus", cls.DEFAULT_SPI_BUS), spi_device=getattr(config, "CC1101SPIDevice", cls.DEFAULT_SPI_DEVICE), output_power=getattr(config, "CC1101OutputPower", cls.DEFAULT_OUTPUT_POWER), + transmit_settle_seconds=getattr( + config, + "CC1101TransmitSettleSeconds", + cls.DEFAULT_TRANSMIT_SETTLE_SECONDS, + ), ) @property @@ -79,9 +95,13 @@ def __init__(self, config, waveform_transmitter): ) def transmit(self, frame, repetition): + if hasattr(self.waveform_transmitter, "set_idle_low"): + self.waveform_transmitter.set_idle_low() with self.radio as radio: radio.set_base_frequency_hertz(self.config.frequency_hz) radio.set_symbol_rate_baud(self.config.symbol_rate_baud) radio.set_output_power(self.config.output_power_table) with radio.asynchronous_transmission(): + if self.config.transmit_settle_seconds > 0: + time.sleep(self.config.transmit_settle_seconds) self.waveform_transmitter.transmit(frame, repetition) diff --git a/defaultConfig.conf b/defaultConfig.conf index 508aba7..bc41309 100644 --- a/defaultConfig.conf +++ b/defaultConfig.conf @@ -36,6 +36,7 @@ CC1101Frequency = 433.42 CC1101SPIBus = 0 CC1101SPIDevice = 0 CC1101OutputPower = 0xC6 +CC1101TransmitSettleSeconds = 0.01 # This parameter, if true will enable the use of HTTPS # (secure HTTP) in the Flask web app or user name and password diff --git a/raw_433_backend.py b/raw_433_backend.py index a2736d5..aa1ffc3 100644 --- a/raw_433_backend.py +++ b/raw_433_backend.py @@ -7,6 +7,8 @@ class Raw433Config: DEFAULT_TXGPIO = 4 def __init__(self, tx_gpio=DEFAULT_TXGPIO): + if tx_gpio is None: + tx_gpio = self.DEFAULT_TXGPIO self.tx_gpio = int(tx_gpio) @classmethod @@ -44,6 +46,12 @@ def transmit(self, frame, repetition): else: self._send_pigpio(frame, repetition) + def set_idle_low(self): + if self.is_pi5: + self._set_idle_low_lgpio() + else: + self._set_idle_low_pigpio() + def _load_pigpio(self): if self.pigpio is not None: return self.pigpio @@ -66,6 +74,7 @@ def _send_pigpio(self, frame, repetition): tx_gpio = self.config.tx_gpio pi.wave_add_new() pi.set_mode(tx_gpio, pigpio.OUTPUT) + pi.write(tx_gpio, 0) wf = [] wf.append(pigpio.pulse(1 << tx_gpio, 0, 9415)) # wake up pulse @@ -111,11 +120,23 @@ def _send_pigpio(self, frame, repetition): pi.wave_delete(wid) pi.stop() + def _set_idle_low_pigpio(self): + pigpio = self._load_pigpio() + pi = pigpio.pi() + + if not pi.connected: + exit() + + tx_gpio = self.config.tx_gpio + pi.set_mode(tx_gpio, pigpio.OUTPUT) + pi.write(tx_gpio, 0) + pi.stop() + def _send_lgpio(self, frame, repetition): lgpio = self._load_lgpio() tx_gpio = self.config.tx_gpio h = lgpio.gpiochip_open(self.lgpio_chip) - lgpio.gpio_claim_output(h, tx_gpio) + lgpio.gpio_claim_output(h, tx_gpio, 0) pulses = [] pulses.append(lgpio.pulse(1, 1, 9415)) # wake up pulse @@ -159,3 +180,12 @@ def _send_lgpio(self, frame, repetition): lgpio.gpio_free(h, tx_gpio) lgpio.gpiochip_close(h) + + def _set_idle_low_lgpio(self): + lgpio = self._load_lgpio() + tx_gpio = self.config.tx_gpio + h = lgpio.gpiochip_open(self.lgpio_chip) + lgpio.gpio_claim_output(h, tx_gpio, 0) + lgpio.gpio_write(h, tx_gpio, 0) + lgpio.gpio_free(h, tx_gpio) + lgpio.gpiochip_close(h) diff --git a/tests/test_cc1101_backend.py b/tests/test_cc1101_backend.py index 9ef6511..e19dd96 100644 --- a/tests/test_cc1101_backend.py +++ b/tests/test_cc1101_backend.py @@ -3,6 +3,7 @@ import sys import types import unittest +from unittest import mock fake_cc1101 = types.ModuleType("cc1101") sys.modules["cc1101"] = fake_cc1101 @@ -67,6 +68,7 @@ def test_config_is_derived_from_app_config(self): CC1101SPIBus=0, CC1101SPIDevice=1, CC1101OutputPower=0xC6, + CC1101TransmitSettleSeconds=0.02, ) config = CC1101Config.from_app_config(app_config) @@ -75,6 +77,7 @@ def test_config_is_derived_from_app_config(self): self.assertEqual(0, config.spi_bus) self.assertEqual(1, config.spi_device) self.assertEqual(0xC6, config.output_power) + self.assertEqual(0.02, config.transmit_settle_seconds) self.assertEqual(1562.5, config.symbol_rate_baud) def test_config_reads_values_from_myconfig_interface(self): @@ -85,6 +88,7 @@ def ReadValue(self, entry, return_type=str, default=None, section=None): "CC1101SPIBus": "0", "CC1101SPIDevice": "1", "CC1101OutputPower": "0xC6", + "CC1101TransmitSettleSeconds": "0.02", } value = values.get(entry) if value is None: @@ -101,6 +105,7 @@ def ReadValue(self, entry, return_type=str, default=None, section=None): self.assertEqual(0, config.spi_bus) self.assertEqual(1, config.spi_device) self.assertEqual(0xC6, config.output_power) + self.assertEqual(0.02, config.transmit_settle_seconds) def test_transmitter_configures_radio_and_calls_waveform_callback(self): config = CC1101Config( @@ -109,14 +114,19 @@ def test_transmitter_configures_radio_and_calls_waveform_callback(self): spi_device=0, output_power=0xC6, ) - waveform_transmitter = types.SimpleNamespace(transmit=lambda frame, repetition: calls.append(("waveform", frame, repetition))) + waveform_transmitter = types.SimpleNamespace( + set_idle_low=lambda: calls.append(("idle_low",)), + transmit=lambda frame, repetition: calls.append(("waveform", frame, repetition)), + ) frame = bytearray([0] * 7) calls = [] transmitter = CC1101Transmitter(config, waveform_transmitter) - transmitter.transmit(frame, 3) + with mock.patch("cc1101_backend.time.sleep") as sleep_mock: + transmitter.transmit(frame, 3) - self.assertEqual([("waveform", frame, 3)], calls) + self.assertEqual([("idle_low",), ("waveform", frame, 3)], calls) + sleep_mock.assert_called_once_with(0.01) self.assertEqual(1, len(FakeRadio.instances)) radio = FakeRadio.instances[0] self.assertEqual(0, radio.spi_bus) diff --git a/tests/test_raw_433_backend.py b/tests/test_raw_433_backend.py index c1b0a44..4c9d27f 100644 --- a/tests/test_raw_433_backend.py +++ b/tests/test_raw_433_backend.py @@ -17,6 +17,9 @@ def wave_add_new(self): def set_mode(self, gpio, mode): self.calls.append(("set_mode", gpio, mode)) + def write(self, gpio, level): + self.calls.append(("write", gpio, level)) + def wave_add_generic(self, waveform): self.calls.append(("wave_add_generic", waveform)) @@ -58,8 +61,12 @@ def gpiochip_open(self, chip): self.calls.append(("gpiochip_open", chip)) return "handle" - def gpio_claim_output(self, handle, gpio): - self.calls.append(("gpio_claim_output", handle, gpio)) + def gpio_claim_output(self, handle, gpio, level=0): + self.calls.append(("gpio_claim_output", handle, gpio, level)) + + def gpio_write(self, handle, gpio, level): + self.calls.append(("gpio_write", handle, gpio, level)) + def pulse(self, level, mask, delay): return ("pulse", level, mask, delay) @@ -93,6 +100,11 @@ def ReadValue(self, entry, return_type=str, default=None, section=None): self.assertEqual(17, config.tx_gpio) + def test_config_uses_default_gpio_when_config_value_is_none(self): + config = Raw433Config(tx_gpio=None) + + self.assertEqual(4, config.tx_gpio) + def test_pigpio_transmitter_sends_waveform(self): transmitter = Raw433Transmitter( Raw433Config(tx_gpio=4), @@ -105,12 +117,28 @@ def test_pigpio_transmitter_sends_waveform(self): fake_pi = FakePi.instances[0] self.assertIn(("set_mode", 4, "output"), fake_pi.calls) + self.assertIn(("write", 4, 0), fake_pi.calls) wave_call = [call for call in fake_pi.calls if call[0] == "wave_add_generic"][0] waveform = wave_call[1] self.assertEqual(("pulse", 1 << 4, 0, 9415), waveform[0]) self.assertIn(("wave_send_once", 7), fake_pi.calls) self.assertIn(("stop",), fake_pi.calls) + def test_pigpio_transmitter_can_set_idle_low(self): + transmitter = Raw433Transmitter( + Raw433Config(tx_gpio=4), + is_pi5=False, + pigpio_module=FakePigpio(), + ) + + transmitter.set_idle_low() + + fake_pi = FakePi.instances[0] + self.assertEqual( + [("set_mode", 4, "output"), ("write", 4, 0), ("stop",)], + fake_pi.calls, + ) + def test_lgpio_transmitter_sends_waveform(self): fake_lgpio = FakeLgpio() transmitter = Raw433Transmitter( @@ -124,12 +152,34 @@ def test_lgpio_transmitter_sends_waveform(self): transmitter.transmit(frame, 1) self.assertEqual(("gpiochip_open", 4), fake_lgpio.calls[0]) - self.assertEqual(("gpio_claim_output", "handle", 4), fake_lgpio.calls[1]) + self.assertEqual(("gpio_claim_output", "handle", 4, 0), fake_lgpio.calls[1]) tx_wave_call = [call for call in fake_lgpio.calls if call[0] == "tx_wave"][0] self.assertEqual("handle", tx_wave_call[1]) self.assertEqual(4, tx_wave_call[2]) self.assertEqual(("pulse", 1, 1, 9415), tx_wave_call[3][0]) + def test_lgpio_transmitter_can_set_idle_low(self): + fake_lgpio = FakeLgpio() + transmitter = Raw433Transmitter( + Raw433Config(tx_gpio=4), + is_pi5=True, + lgpio_module=fake_lgpio, + lgpio_chip=4, + ) + + transmitter.set_idle_low() + + self.assertEqual( + [ + ("gpiochip_open", 4), + ("gpio_claim_output", "handle", 4, 0), + ("gpio_write", "handle", 4, 0), + ("gpio_free", "handle", 4), + ("gpiochip_close", "handle"), + ], + fake_lgpio.calls, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_rf_backend.py b/tests/test_rf_backend.py index 99f618c..ddee6c2 100644 --- a/tests/test_rf_backend.py +++ b/tests/test_rf_backend.py @@ -119,6 +119,7 @@ def test_cc1101_backend_configures_radio_and_reuses_waveform(self): config.CC1101SPIDevice = 0 config.CC1101OutputPower = 0xC6 shutter = operateShutters.Shutter(config=config) + shutter.rf_transmitter.waveform_transmitter.set_idle_low = mock.Mock() shutter.rf_transmitter.waveform_transmitter.transmit = mock.Mock() shutter.sendCommand("279620", shutter.buttonDown, 3) @@ -132,6 +133,7 @@ def test_cc1101_backend_configures_radio_and_reuses_waveform(self): self.assertIn(("symbol_rate", 1562.5), radio.calls) self.assertIn(("output_power", (0, 0xC6)), radio.calls) self.assertLess(radio.calls.index(("async_enter",)), radio.calls.index(("async_exit",))) + shutter.rf_transmitter.waveform_transmitter.set_idle_low.assert_called_once_with() shutter.rf_transmitter.waveform_transmitter.transmit.assert_called_once_with(shutter.frame, 3) self.assertEqual(2, config.Shutters["279620"]["code"]) @@ -140,11 +142,16 @@ def test_cc1101_backend_reuses_transmitter_between_commands(self): config.RFBackend = "cc1101" shutter = operateShutters.Shutter(config=config) + shutter.rf_transmitter.waveform_transmitter.set_idle_low = mock.Mock() shutter.rf_transmitter.waveform_transmitter.transmit = mock.Mock() shutter.sendCommand("279620", shutter.buttonUp, 1) shutter.sendCommand("279620", shutter.buttonStop, 1) self.assertEqual(1, len(FakeRadio.instances)) + self.assertEqual( + [mock.call(), mock.call()], + shutter.rf_transmitter.waveform_transmitter.set_idle_low.call_args_list, + ) self.assertEqual( [mock.call(shutter.frame, 1), mock.call(shutter.frame, 1)], shutter.rf_transmitter.waveform_transmitter.transmit.call_args_list, From aa024e3e56848cdb260ffa0b518020cc4520d6ad Mon Sep 17 00:00:00 2001 From: Idan Freiberg Date: Sat, 6 Jun 2026 06:36:14 +0300 Subject: [PATCH 13/13] docs: capture working cc1101 tuning --- README.md | 8 +++++--- cc1101_backend.py | 2 +- defaultConfig.conf | 3 ++- tests/test_cc1101_backend.py | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2080af2..3367298 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,16 @@ Enable SPI on the Pi, then set the top-level RF backend in `operateShutters.conf ```ini TXGPIO = 4 +SendRepeat = 5 RFBackend = cc1101 CC1101Frequency = 433.42 CC1101SPIBus = 0 CC1101SPIDevice = 0 CC1101OutputPower = 0xC6 -CC1101TransmitSettleSeconds = 0.01 +CC1101TransmitSettleSeconds = 0.05 ``` -`CC1101TransmitSettleSeconds` keeps GDO0 low briefly after the CC1101 enters asynchronous TX mode before the Somfy waveform starts. The default `0.01` is intended to avoid clipping the first wake-up pulse on modules that need a short TX settle time. +`CC1101TransmitSettleSeconds` keeps GDO0 low briefly after the CC1101 enters asynchronous TX mode before the Somfy waveform starts. `0.05` has worked reliably with an E07-M1101D-SMA on a Raspberry Pi 4. If one shade still misses commands, increase `SendRepeat` first because it sends more copies of the same Somfy frame. CC1101 connection photos to add: @@ -162,10 +163,11 @@ RFBackend = raw_433 For the E07-M1101D-SMA / CC1101 module, set: ```ini +SendRepeat = 5 RFBackend = cc1101 CC1101Frequency = 433.42 CC1101OutputPower = 0xC6 -CC1101TransmitSettleSeconds = 0.01 +CC1101TransmitSettleSeconds = 0.05 ``` Next, test the Python environment by typing: diff --git a/cc1101_backend.py b/cc1101_backend.py index c2dcbf6..1b3f443 100644 --- a/cc1101_backend.py +++ b/cc1101_backend.py @@ -10,7 +10,7 @@ class CC1101Config: DEFAULT_SPI_BUS = 0 DEFAULT_SPI_DEVICE = 0 DEFAULT_OUTPUT_POWER = 0xC6 - DEFAULT_TRANSMIT_SETTLE_SECONDS = 0.01 + DEFAULT_TRANSMIT_SETTLE_SECONDS = 0.05 SYMBOL_RATE_BAUD = 1562.5 def __init__( diff --git a/defaultConfig.conf b/defaultConfig.conf index bc41309..f510564 100644 --- a/defaultConfig.conf +++ b/defaultConfig.conf @@ -16,6 +16,7 @@ Longitude = 0 # Repeat each command a certain number of times. This is to ensure it works # if the remote is far away from the shutter and sometimes EMI prevents a # signal to go through +# E07-M1101D-SMA / CC1101 deployments that miss commands may need SendRepeat = 5. # This option only applies if a shutter is raised or lowered in full. If # a shutter is only raised or lowered for a given amount of seconds, this # option does not apply for obvious reasons. @@ -36,7 +37,7 @@ CC1101Frequency = 433.42 CC1101SPIBus = 0 CC1101SPIDevice = 0 CC1101OutputPower = 0xC6 -CC1101TransmitSettleSeconds = 0.01 +CC1101TransmitSettleSeconds = 0.05 # This parameter, if true will enable the use of HTTPS # (secure HTTP) in the Flask web app or user name and password diff --git a/tests/test_cc1101_backend.py b/tests/test_cc1101_backend.py index e19dd96..b2cbb7c 100644 --- a/tests/test_cc1101_backend.py +++ b/tests/test_cc1101_backend.py @@ -126,7 +126,7 @@ def test_transmitter_configures_radio_and_calls_waveform_callback(self): transmitter.transmit(frame, 3) self.assertEqual([("idle_low",), ("waveform", frame, 3)], calls) - sleep_mock.assert_called_once_with(0.01) + sleep_mock.assert_called_once_with(0.05) self.assertEqual(1, len(FakeRadio.instances)) radio = FakeRadio.instances[0] self.assertEqual(0, radio.spi_bus)