Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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.*
*.log.*

.vscode
21 changes: 18 additions & 3 deletions Home Assistant/addon/pi_somfy/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion Home Assistant/addon/pi_somfy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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/*

Expand Down
10 changes: 10 additions & 0 deletions Home Assistant/addon/pi_somfy/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ privileged:
- SYS_RAWIO
devices:
- /dev/mem
- /dev/vcio
- /dev/gpiochip0
- /dev/gpiochip4
map:
Expand All @@ -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
39 changes: 39 additions & 0 deletions Home Assistant/addon/pi_somfy/patch_pigpiod.py
Original file line number Diff line number Diff line change
@@ -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)")
29 changes: 28 additions & 1 deletion Home Assistant/addon/pi_somfy/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions Home Assistant/custom_components/pi_somfy/cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,45 @@ OK. now this all should look like this. Note that some of the pictures are a bit
![Pi Connection](documentation/Connection.jpg)<br/>
![RF Transmitter Connection](documentation/Sender.jpg)<br/>

### 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.
Expand Down Expand Up @@ -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. <br/>![Screenshot](documentation/p3.png)<br/>

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

Expand Down
107 changes: 104 additions & 3 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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---------------------------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading