diff --git a/.gitignore b/.gitignore
index 39672af..595d462 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,11 @@
/.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.*
+
+.vscode
\ No newline at end of file
diff --git a/Home Assistant/addon/pi_somfy/DOCS.md b/Home Assistant/addon/pi_somfy/DOCS.md
index 4bb3dd8..f9e1fde 100644
--- a/Home Assistant/addon/pi_somfy/DOCS.md
+++ b/Home Assistant/addon/pi_somfy/DOCS.md
@@ -19,9 +19,24 @@ This add-on runs [Pi-Somfy](https://github.com/Nickduino/Pi-Somfy) directly on y
## Configuration
-| Option | Default | Description |
-|------------|---------|------------------------------------------------|
-| `gpio_pin` | `4` | GPIO pin number for the 433.42 MHz transmitter |
+| Option | Default | Description |
+|---------------|---------|---------------------------------------------------------------------------|
+| `gpio_pin` | `4` | GPIO pin number for the 433.42 MHz transmitter |
+| `rx_gpio_pin` | (none) | GPIO wired to a CC1101 receiver's data output. Leave blank to disable the physical-remote receiver entirely. |
+| `spi_sck` | `21` | CC1101 bit-banged SPI clock GPIO (only used when `rx_gpio_pin` is set) |
+| `spi_mosi` | `20` | CC1101 bit-banged SPI MOSI GPIO (only used when `rx_gpio_pin` is set) |
+| `spi_miso` | `19` | CC1101 bit-banged SPI MISO GPIO (only used when `rx_gpio_pin` is set) |
+| `spi_csn` | `16` | CC1101 bit-banged SPI chip-select GPIO (only used when `rx_gpio_pin` is set) |
+
+### Physical remote receiver (optional)
+
+Setting `rx_gpio_pin` enables listening for physical Somfy RTS remote button
+presses via a CC1101 receiver module, so a physical remote and the app stay
+in sync. Pairing a physical remote to a shutter is not yet exposed in the
+web UI — add it manually to
+`[PhysicalRemotes]` in `/data/operateShutters.conf` (map the remote's
+address to a comma-separated list of shutterId(s), same format as the
+`[Shutters]` section).
## Web UI
diff --git a/Home Assistant/addon/pi_somfy/Dockerfile b/Home Assistant/addon/pi_somfy/Dockerfile
index ddb20d0..28e0c71 100644
--- a/Home Assistant/addon/pi_somfy/Dockerfile
+++ b/Home Assistant/addon/pi_somfy/Dockerfile
@@ -3,6 +3,7 @@ FROM ${BUILD_FROM}
# Install system dependencies (Debian base)
# Build lgpio from source (for Pi 5) and pigpiod from source (for Pi 1/2/3/4)
+COPY patch_pigpiod.py /tmp/patch_pigpiod.py
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
@@ -24,11 +25,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& pip3 install --no-cache-dir --break-system-packages . \
&& cd /tmp \
&& git clone --depth 1 https://github.com/joan2937/pigpio.git \
+ && python3 /tmp/patch_pigpiod.py /tmp/pigpio/pigpiod.c \
&& cd pigpio \
&& make \
&& make install \
&& cd / \
- && rm -rf /tmp/lg /tmp/pigpio \
+ && rm -rf /tmp/lg /tmp/pigpio /tmp/patch_pigpiod.py \
&& apt-get purge -y --auto-remove make gcc g++ libc6-dev swig python3-dev \
&& rm -rf /var/lib/apt/lists/*
diff --git a/Home Assistant/addon/pi_somfy/config.yaml b/Home Assistant/addon/pi_somfy/config.yaml
index 681ec42..b08c084 100644
--- a/Home Assistant/addon/pi_somfy/config.yaml
+++ b/Home Assistant/addon/pi_somfy/config.yaml
@@ -20,6 +20,7 @@ privileged:
- SYS_RAWIO
devices:
- /dev/mem
+ - /dev/vcio
- /dev/gpiochip0
- /dev/gpiochip4
map:
@@ -32,7 +33,16 @@ ports_description:
watchdog: "http://[HOST]:[PORT:80]/cmd/getConfig"
options:
gpio_pin: 4
+ spi_sck: 21
+ spi_mosi: 20
+ spi_miso: 19
+ spi_csn: 16
schema:
gpio_pin: "int(0,27)"
+ rx_gpio_pin: "int(0,27)?"
+ spi_sck: "int(0,27)"
+ spi_mosi: "int(0,27)"
+ spi_miso: "int(0,27)"
+ spi_csn: "int(0,27)"
discovery:
- pi_somfy
diff --git a/Home Assistant/addon/pi_somfy/patch_pigpiod.py b/Home Assistant/addon/pi_somfy/patch_pigpiod.py
new file mode 100644
index 0000000..abba040
--- /dev/null
+++ b/Home Assistant/addon/pi_somfy/patch_pigpiod.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python3
+"""Patch pigpiod's unconditional /dev/pigerr error-FIFO setup (build-time only).
+
+pigpiod.c's main() always does unlink()+mkfifo()+chmod()+freopen() on
+/dev/pigerr, with no command-line flag to disable it (verified against
+joan2937/pigpio master — -f only gates the /dev/pigpio *command* FIFO, not
+this error-output companion). In a container where /dev is read-only except
+for the specific device nodes this add-on's config.yaml requests, mkfifo()
+silently fails and the following chmod() aborts the whole daemon.
+
+This add-on only talks to pigpiod over its TCP socket interface, so the
+error FIFO is unneeded — replace its setup with a direct assignment to the
+process's own stderr, which the container already captures as add-on logs.
+"""
+import re
+import sys
+
+path = sys.argv[1]
+with open(path) as f:
+ src = f.read()
+
+pattern = re.compile(
+ r"/\*\s*create pipe for error reporting\s*\*/.*?"
+ r"errFifo\s*=\s*freopen\(PI_ERRFIFO,\s*\"w\+\",\s*stderr\);",
+ re.DOTALL)
+
+patched, count = pattern.subn(
+ "/* patched: container /dev is read-only, skip the error FIFO */\n"
+ " errFifo = stderr;",
+ src, count=1)
+
+if count != 1:
+ sys.exit("patch_pigpiod.py: expected pigpiod.c pattern not found — "
+ "pigpio source may have changed upstream, update the patch")
+
+with open(path, "w") as f:
+ f.write(patched)
+
+print("pigpiod.c patched: error FIFO disabled (using stderr directly)")
diff --git a/Home Assistant/addon/pi_somfy/run.sh b/Home Assistant/addon/pi_somfy/run.sh
index f3354c3..6f4e03b 100644
--- a/Home Assistant/addon/pi_somfy/run.sh
+++ b/Home Assistant/addon/pi_somfy/run.sh
@@ -20,6 +20,30 @@ else
sed -i "/^\[General\]/a TXGPIO = ${GPIO_PIN}" "${CONFIG_FILE}"
fi
+# RX receiver (physical Somfy remotes) is optional — only write RXGPIO/RXSpi*
+# into the config file if the user set rx_gpio_pin, so operateShutters.py's
+# `config.RXGPIO is not None` gate stays unset (receiver disabled) otherwise.
+if bashio::config.has_value 'rx_gpio_pin'; then
+ RX_GPIO_PIN=$(bashio::config 'rx_gpio_pin')
+ SPI_SCK=$(bashio::config 'spi_sck')
+ SPI_MOSI=$(bashio::config 'spi_mosi')
+ SPI_MISO=$(bashio::config 'spi_miso')
+ SPI_CSN=$(bashio::config 'spi_csn')
+ bashio::log.info "RX receiver enabled: GPIO ${RX_GPIO_PIN} (CC1101)"
+
+ for entry in "RXGPIO:${RX_GPIO_PIN}" "RXSpiSCK:${SPI_SCK}" "RXSpiMOSI:${SPI_MOSI}" "RXSpiMISO:${SPI_MISO}" "RXSpiCSN:${SPI_CSN}"; do
+ key="${entry%%:*}"
+ value="${entry#*:}"
+ if grep -q "^${key}" "${CONFIG_FILE}"; then
+ sed -i "s/^${key}.*/${key} = ${value}/" "${CONFIG_FILE}"
+ else
+ sed -i "/^\[General\]/a ${key} = ${value}" "${CONFIG_FILE}"
+ fi
+ done
+else
+ bashio::log.info "RX receiver disabled (set rx_gpio_pin in add-on options to enable)"
+fi
+
# Ensure log location exists and is writable
sed -i "s|^LogLocation.*|LogLocation = /data/|" "${CONFIG_FILE}"
@@ -50,7 +74,10 @@ if [ "${IS_PI5}" = true ]; then
bashio::log.info "Pi 5 detected — using lgpio (no pigpiod needed)"
else
bashio::log.info "Starting pigpiod..."
- pigpiod -l -m
+ # Deliberately not passing -m (disable alerts): -m silently prevents
+ # pi.callback() from ever delivering edge notifications, which the RX
+ # receiver needs when rx_gpio_pin is set.
+ pigpiod -l
sleep 1
if ! pgrep -x pigpiod > /dev/null; then
diff --git a/Home Assistant/custom_components/pi_somfy/cover.py b/Home Assistant/custom_components/pi_somfy/cover.py
index 7505dcb..77dc1a2 100644
--- a/Home Assistant/custom_components/pi_somfy/cover.py
+++ b/Home Assistant/custom_components/pi_somfy/cover.py
@@ -100,10 +100,22 @@ def is_closing(self) -> bool:
@callback
def _handle_coordinator_update(self) -> None:
- """Handle updated data from the coordinator."""
- pos = self.current_cover_position
- if pos is not None and (pos <= 0 or pos >= 100):
- self._moving = None
+ """Handle updated data from the coordinator.
+
+ Reads Pi-Somfy's own movement-state signal (populated by any trigger
+ source — the app, a physical remote, or the web UI — not just
+ commands issued through Home Assistant) rather than only clearing
+ the optimistic local flag once position settles. This overwrites
+ the optimistic set from async_open_cover/async_close_cover/
+ async_set_cover_position with the server-authoritative value as soon
+ as the next poll lands, which also happens to confirm HA-issued
+ commands almost immediately since the coordinator refreshes right
+ after sending one.
+ """
+ shutter = self.coordinator.data.get(self._shutter_id)
+ if shutter is not None:
+ state = shutter.get("movementState")
+ self._moving = state if state in ("opening", "closing") else None
self.async_write_ha_state()
async def async_open_cover(self, **kwargs: Any) -> None:
diff --git a/README.md b/README.md
index 5a99586..c24735b 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,45 @@ OK. now this all should look like this. Note that some of the pictures are a bit


+### 2.1 Receiver (optional) — tracking physical remotes
+
+Everything above covers sending commands. Optionally, Pi-Somfy can also
+*listen* for presses on your existing physical Somfy remotes: a small
+receiver module decodes the same RTS radio protocol your remotes already
+speak, so pressing one updates Pi-Somfy's tracked shutter position (and
+Home Assistant, via MQTT or the custom component) immediately — not just
+commands issued through the app itself.
+
+This needs a second, separate piece of hardware: a CC1101 transceiver
+module (~$3) tuned in software to exactly 433.42 MHz. SPI is bit-banged on
+ordinary GPIOs and only used once at startup, so no host `config.txt`
+changes or reboots are needed. Match module pins by silkscreen **label**,
+not position (MOSI may be printed `SI`, MISO `SO`):
+
+| CC1101 pin | Signal | Default GPIO | Physical pin (Pi 4) |
+|---|---|---|---|
+| VCC | 3.3 V supply | — | 17 (or 1) — **never 5 V** |
+| GND | ground | — | 39 |
+| SCK | SPI clock | GPIO 21 | 40 |
+| MOSI (SI) | SPI data → radio | GPIO 20 | 38 |
+| MISO (SO) | SPI data → Pi | GPIO 19 | 35 |
+| CSN | chip select | GPIO 16 | 36 |
+| GDO0 | demodulated data out | GPIO 26 | 37 |
+| GDO2 | — | not connected | — |
+| ANT | antenna | — | 17 cm solid-core wire, **required** |
+
+To enable it, set `RXGPIO` (and, if wired to non-default pins,
+`RXSpiSCK`/`RXSpiMOSI`/`RXSpiMISO`/`RXSpiCSN`) in `operateShutters.conf`'s
+`[General]` section — leave `RXGPIO` unset/commented out to run without a
+receiver, exactly as before. If you're running the Home Assistant add-on,
+the same options are exposed directly in its configuration page as
+`rx_gpio_pin`/`spi_sck`/`spi_mosi`/`spi_miso`/`spi_csn`.
+
+Once enabled, pair a physical remote to a shutter from the web UI's
+"Physical Remotes" section (see §5 below): press a button on the remote,
+find it listed under "Recently Heard", and assign it to one or more
+shutters.
+
## 3 Software
If you are new to using a Raspberry Pi and Linux please refer to other sources for coming up to speed with the environment. Having a base knowledge will go a long way. This [site](https://www.raspberrypi.org/help/) is a great place to start if you are new to these topics.
@@ -206,6 +245,8 @@ Click the "Add" button, select the name for your shutter (this is also the name
1. Finally, it's time to program your shutters schedule. To do so, use the "Scheduled Operations" menu.

+1. If you've wired up the optional CC1101 receiver (§2.1), pair your physical remotes using the "Physical Remotes" menu. Press a button on the remote, find it listed under "Recently Heard", click the assign icon, and pick which shutter(s) it should control.
+
## 6 Alexa Integration
diff --git a/config.py b/config.py
index 8973050..cc90f21 100644
--- a/config.py
+++ b/config.py
@@ -132,6 +132,16 @@ def __init__(self, filename = None, section = None, log = None):
self.ShuttersByName = {}
self.Schedule = {}
self.Password = ""
+ self.PhysicalRemotes = {}
+ self.ShutterPositions = {}
+ # Optional (unlike TXGPIO, not guaranteed present in defaultConfig.conf)
+ # — must default to None so `is not None` checks don't raise
+ # AttributeError on a config file that omits them.
+ self.RXGPIO = None
+ self.RXSpiSCK = None
+ self.RXSpiMOSI = None
+ self.RXSpiMISO = None
+ self.RXSpiCSN = None
try:
self.config = RawConfigParser(strict=False)
@@ -150,7 +160,8 @@ 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, 'RTS_Address': str, "Password": str,
+ 'RXGPIO': int, 'RXSpiSCK': int, 'RXSpiMOSI': int, 'RXSpiMISO': int, 'RXSpiCSN': int}
for key, type in parameters.items():
try:
@@ -201,7 +212,23 @@ def LoadConfig(self):
except Exception as e1:
self.LogErrorLine("Missing config file or config file entries in Section Scheduler for key "+key+": " + str(e1))
return False
-
+
+ remotes = self.GetList(section="PhysicalRemotes")
+ for key, value in remotes:
+ try:
+ self.PhysicalRemotes[key] = [s.strip() for s in value.split(",")]
+ except Exception as e1:
+ self.LogErrorLine("Missing config file or config file entries in Section PhysicalRemotes for key "+key+": " + str(e1))
+ return False
+
+ positions = self.GetList(section="ShutterPositions")
+ for key, value in positions:
+ try:
+ self.ShutterPositions[key] = int(value)
+ except Exception as e1:
+ self.LogErrorLine("Missing config file or config file entries in Section ShutterPositions for key "+key+": " + str(e1))
+ return False
+
return True
#---------------------MyConfig::setLocation---------------------------------
@@ -314,7 +341,14 @@ def WriteValue(self, Entry, Value, section = None):
last_data_line = i
if not in_target_section:
- raise Exception("NOT ABLE TO FIND SECTION:" + sect)
+ # Auto-create the missing section rather than failing:
+ # fresh installs get every section from defaultConfig.conf,
+ # but a pre-M1 config file being upgraded in place may be
+ # missing newer sections (e.g. [PhysicalRemotes],
+ # [ShutterPositions]) entirely.
+ lines.append("")
+ lines.append("[" + sect + "]")
+ section_header_line = len(lines) - 1
if key_line >= 0:
# Replace existing key
@@ -346,3 +380,70 @@ def WriteValue(self, Entry, Value, section = None):
except Exception as e1:
self.LogError("Error in WriteValue: " + str(e1))
return False
+
+ #---------------------MyConfig::RemoveValue----------------------------------
+ # Deletes a single key from a section — WriteValue can only replace/insert
+ # a key, never remove one, which a real "unassign" (e.g. [PhysicalRemotes])
+ # needs, unlike [Shutters]'s soft-delete-via-flag convention which doesn't
+ # fit a plain "key = value" row shape. Mirrors WriteValue's exact
+ # scan-then-atomic-rewrite pattern. Idempotent: removing an already-absent
+ # key (or a section that doesn't exist at all) is not an error.
+ def RemoveValue(self, Entry, section = None):
+
+ sect = section if section is not None else self.Section
+
+ try:
+ with self.CriticalLock:
+ with open(self.FileName, 'r') as f:
+ lines = f.read().splitlines()
+
+ in_target_section = False
+ key_line = -1
+
+ for i, line in enumerate(lines):
+ m_sect = self._RE_SECTION.match(line)
+ if m_sect:
+ if in_target_section:
+ break # reached next section, stop
+ if m_sect.group(1).strip().lower() == sect.lower():
+ in_target_section = True
+ continue
+
+ if in_target_section:
+ m_kv = self._RE_KEY_VALUE.match(line)
+ if m_kv and m_kv.group(2).strip() == Entry:
+ key_line = i
+ break
+
+ if not in_target_section or key_line < 0:
+ return True # nothing to remove — idempotent, not an error
+
+ del lines[key_line]
+ content = "\n".join(lines) + "\n"
+
+ # Atomic write: write to temp file, then replace
+ tmp = self.FileName + ".tmp"
+ with open(tmp, 'w') as f:
+ f.write(content)
+ f.flush()
+ os.fsync(f.fileno())
+ if sys.version_info[0] >= 3:
+ os.replace(tmp, self.FileName)
+ else:
+ if os.path.exists(self.FileName):
+ os.remove(self.FileName)
+ os.rename(tmp, self.FileName)
+
+ # RawConfigParser.read() only merges in additions/updates, it
+ # never drops an option that disappeared from the file — the
+ # removed key would otherwise stay stale in the cached view.
+ try:
+ self.config.remove_option(sect, Entry)
+ except Exception:
+ pass
+ self.config.read(self.FileName)
+ return True
+
+ except Exception as e1:
+ self.LogError("Error in RemoveValue: " + str(e1))
+ return False
diff --git a/defaultConfig.conf b/defaultConfig.conf
index bb0599b..fe7c046 100644
--- a/defaultConfig.conf
+++ b/defaultConfig.conf
@@ -49,6 +49,21 @@ HTTPSPort = 443
# each instance is set to a different value to avoid possible conflicts
RTS_Address = 0x279620
+# (Optional) GPIO wired to the RTS receiver's demodulated data output
+# (CC1101 GDO0). When set, Pi-Somfy listens for physical Somfy remote
+# button presses and updates the tracked shutter position accordingly
+# Leave commented out to disable the receiver entirely.
+# Example: GPIO 26 (physical pin 37)
+# RXGPIO = 26
+
+# (Optional) Bit-banged SPI pins used to configure the CC1101 receiver
+# module. Only used when RXGPIO is set above. Defaults shown below; change
+# only if wired differently.
+#RXSpiSCK = 21
+#RXSpiMOSI = 20
+#RXSpiMISO = 19
+#RXSpiCSN = 16
+
###################################################################
###################################################################
## LIST OF ALL SHUTTERS REGISTERED
@@ -131,4 +146,29 @@ EnableDiscovery = true
#
[Scheduler]
+###################################################################
+###################################################################
+## PHYSICAL REMOTE PAIRING (used by the RTS receiver, when RXGPIO is set)
+###################################################################
+###################################################################
+#
+# Maps a physical Somfy remote's address (24bit identifier, same format as
+# [Shutters]) to the shutterId(s) it should control. The config value is a
+# comma delimited list of shutterId(s) (the addresses used as keys in
+# [Shutters]) — list more than one to fan a single remote channel out to a
+# group of shutters.
+#
+# Example: a remote at address 0x27ABCD operating two shutters:
+#0x27ABCD = 0x279620, 0x14A2C7
+#
+[PhysicalRemotes]
+
+
+# Last known position (0-100 %) of each shutter, keyed by the same
+# shutterId used in [Shutters]. Automatically maintained by Pi-Somfy as
+# shutters move — like [ShutterRollingCodes], you normally won't edit this
+# by hand. Seeds the tracked position on startup so it isn't lost across a
+# restart.
+#
+[ShutterPositions]
diff --git a/html/index.html b/html/index.html
index 02027d7..ca88972 100755
--- a/html/index.html
+++ b/html/index.html
@@ -228,6 +228,58 @@