├── tests ├── __init__.py ├── test_main_pytest.py ├── test_turn_led_pytest.py ├── test_config_pytest.py └── test_build_arduino_sketch_and_deploy_pytest.py ├── docs ├── GPIO.png └── report_weather.README.md ├── .gitignore ├── setup.py ├── config.txt.default ├── send-email.sh ├── .github ├── dependabot.yml └── workflows │ ├── python-tests.yml │ └── build-arduino-sketch.yml ├── systemd-services ├── shutdown.service ├── door-sensor.service ├── video-recorder.service └── minidlna.service ├── home_automation ├── config.py ├── turn_led.py ├── __main__.py ├── report_weather.py ├── remote_control.py └── build_arduino_sketch_and_deploy.py ├── requirements.txt ├── bin ├── shutdown.py ├── 20h.py └── auto-mute-strava-activities.py ├── strava-logger.html ├── ArduinoSketch └── ArduinoSketch.ino ├── README.md ├── video_recorder.py └── door-sensor.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/GPIO.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rpellerin/raspberry-pi-home-automation/HEAD/docs/GPIO.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python virtual environment 2 | .venv/ 3 | 4 | # Secrets 5 | config.txt 6 | 7 | # Other 8 | __pycache__/ 9 | *.egg-info/ 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="home_automation", 5 | version="0.1.0", 6 | packages=find_packages(include=["home_automation", "home_automation.*"]), 7 | ) 8 | -------------------------------------------------------------------------------- /config.txt.default: -------------------------------------------------------------------------------- 1 | [weatherstation] 2 | GOOGLE_SCRIPTS_URL="https://script.google.com/macros/s/XYZ/exec' 3 | 4 | [pushover] 5 | PUSHOVER_USER= 6 | PUSHOVER_TOKEN= 7 | 8 | [arduino] 9 | RF_ON_SIGNAL= 10 | RF_OFF_SIGNAL= 11 | -------------------------------------------------------------------------------- /send-email.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | subject="$1" 4 | filename="$2" 5 | 6 | date 7 | if [ -z "$filename" ]; then 8 | # $filename is null 9 | echo "On `date` (no attachment)" | mail -s "$subject" root@localhost 10 | else 11 | echo "On `date`" | mail -s "$subject" -A "$filename" root@localhost 12 | fi 13 | date 14 | echo 'Email sent' 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "pip" 7 | directory: "/" # Location of package manifests 8 | schedule: 9 | interval: "monthly" 10 | -------------------------------------------------------------------------------- /systemd-services/shutdown.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Shutdown with Python daemon 3 | After=local-fs.target network-online.target 4 | 5 | [Service] 6 | Restart=always 7 | RestartSec=3 8 | PIDFile=/run/python_shutdown.pid 9 | ExecStart=/home/pi/raspberry-pi-home-automation/.venv/bin/python3 -u /home/pi/raspberry-pi-home-automation/bin/shutdown.py 10 | Type=simple 11 | StandardError=null 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /home_automation/config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | 4 | repo_path = os.path.dirname(os.path.realpath(__file__)) 5 | 6 | 7 | def get_config(): 8 | config = configparser.ConfigParser() 9 | config.read_file(open(repo_path + "/../config.txt")) 10 | return config 11 | 12 | 13 | GOOGLE_SCRIPTS_URL = get_config().get("weatherstation", "GOOGLE_SCRIPTS_URL") 14 | PUSHOVER_USER = get_config().get("pushover", "PUSHOVER_USER", fallback=None) 15 | PUSHOVER_TOKEN = get_config().get("pushover", "PUSHOVER_TOKEN", fallback=None) 16 | -------------------------------------------------------------------------------- /systemd-services/door-sensor.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Detect door state changes 3 | After=local-fs.target network-online.target 4 | 5 | [Service] 6 | Restart=always 7 | RestartSec=3 8 | PIDFile=/run/python_door-sensor.pid 9 | User=pi 10 | ExecStart=/home/pi/raspberry-pi-home-automation/.venv/bin/python3 -u /home/pi/raspberry-pi-home-automation/door-sensor.py 11 | Type=simple 12 | StandardOutput=append:/var/log/door-sensor.stdout 13 | StandardError=append:/var/log/door-sensor.stderr 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /systemd-services/video-recorder.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Records video through the Raspberry Pi camera 3 | After=local-fs.target 4 | 5 | [Service] 6 | Restart=always 7 | RestartSec=3 8 | PIDFile=/run/python_video-recorder.pid 9 | User=pi 10 | ExecStart=/home/pi/raspberry-pi-home-automation/.venv/bin/python3 -u /home/pi/raspberry-pi-home-automation/video_recorder.py 11 | Type=simple 12 | StandardOutput=append:/var/log/video-recorder.stdout 13 | StandardError=append:/var/log/video-recorder.stderr 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Those that are commented come by default on Linux (I think!), or through the apt packages installed in README.md 2 | 3 | beautifulsoup4==4.14.3 4 | #certifi==2024.2.2 5 | #chardet==3.0.4 6 | #gpiozero==1.6.2 # installed through the APT package `python3-gpiozero` 7 | #idna==2.10 8 | pyserial==3.5 9 | pytest==9.0.1 10 | redis==7.1.0 11 | requests==2.32.5 12 | RPi.bme280==0.2.4 13 | #RPi.GPIO==0.7.1 # installed through the APT package `python3-rpi.gpio`, presented by default on Raspbian 14 | smbus2==0.5.0 15 | #urllib3==1.26.18 16 | yt-dlp==2025.11.12 17 | -------------------------------------------------------------------------------- /systemd-services/minidlna.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=MiniDLNA lightweight DLNA/UPnP-AV server 3 | Documentation=man:minidlnad(1) man:minidlna.conf(5) 4 | After=local-fs.target remote-fs.target autofs.service 5 | 6 | [Service] 7 | User=minidlna 8 | Group=minidlna 9 | 10 | Environment=CONFIGFILE=/etc/minidlna.conf 11 | Environment=DAEMON_OPTS=-r 12 | EnvironmentFile=-/etc/default/minidlna 13 | 14 | RuntimeDirectory=minidlna 15 | LogsDirectory=minidlna 16 | PIDFile=/run/minidlna/minidlna.pid 17 | ExecStart=/usr/local/sbin/minidlnad -f $CONFIGFILE -P /run/minidlna/minidlna.pid -S $DAEMON_OPTS 18 | 19 | 20 | [Install] 21 | WantedBy=multi-user.target 22 | -------------------------------------------------------------------------------- /home_automation/turn_led.py: -------------------------------------------------------------------------------- 1 | import RPi.GPIO as GPIO 2 | import sys 3 | import logging 4 | 5 | format_logs = "%(asctime)s: %(message)s" 6 | logging.basicConfig(stream=sys.stdout, format=format_logs, level=logging.INFO) 7 | 8 | GPIO.setmode(GPIO.BCM) 9 | 10 | 11 | def turn_off(): 12 | # GPIO.setwarnings(False) 13 | GPIO.setup(23, GPIO.OUT) # 8th PIN on the external ROW of GPIO pins 14 | GPIO.output(23, GPIO.LOW) 15 | logging.info("LED off") 16 | 17 | 18 | def turn_on(): 19 | GPIO.setup(23, GPIO.OUT) # 8th PIN on the external ROW of GPIO pins 20 | GPIO.output(23, GPIO.HIGH) 21 | logging.info("LED on") 22 | 23 | 24 | def cleanup(): 25 | GPIO.cleanup() 26 | logging.info("LED cleaned up") 27 | -------------------------------------------------------------------------------- /home_automation/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from .remote_control import run as remote_control 3 | 4 | 5 | def run(action): 6 | if action == None: 7 | print("No action given.", file=sys.stderr) 8 | return False 9 | 10 | if action == "report_weather": 11 | from .report_weather import send_report # Lazy import 12 | 13 | return send_report() 14 | elif action == "remote_control": 15 | from .remote_control import run as remote_control 16 | 17 | return remote_control() 18 | elif action == "build_arduino_sketch_and_deploy": 19 | from .build_arduino_sketch_and_deploy import build_and_deploy 20 | 21 | return build_and_deploy() 22 | else: 23 | print(f"Unknown action: {action}", file=sys.stderr) 24 | return False 25 | 26 | 27 | if __name__ == "__main__": 28 | action = sys.argv[1] if len(sys.argv) >= 2 else None 29 | if not run(action): 30 | sys.exit(1) 31 | -------------------------------------------------------------------------------- /tests/test_main_pytest.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, mock_open 2 | import pytest 3 | from importlib import reload 4 | import subprocess 5 | 6 | 7 | class TestConfig: 8 | def test_warns_when_calling_the_cli_without_an_action(self): 9 | output = subprocess.run( 10 | ["python3", "-m", "home_automation"], 11 | stdout=subprocess.PIPE, 12 | stderr=subprocess.PIPE, 13 | ) 14 | assert output.stdout.decode("utf-8") == "" 15 | assert output.stderr.decode("utf-8") == "No action given.\n" 16 | assert output.returncode == 1 17 | 18 | def test_warns_when_calling_the_cli_with_an_unknown_action(self): 19 | output = subprocess.run( 20 | ["python3", "-m", "home_automation", "yolo"], 21 | stdout=subprocess.PIPE, 22 | stderr=subprocess.PIPE, 23 | ) 24 | assert output.stdout.decode("utf-8") == "" 25 | assert output.stderr.decode("utf-8") == "Unknown action: yolo\n" 26 | assert output.returncode == 1 27 | -------------------------------------------------------------------------------- /.github/workflows/python-tests.yml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: [push, workflow_dispatch] 4 | 5 | jobs: 6 | python-tests: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Set up Python 11 | uses: actions/setup-python@v4 12 | # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 13 | with: 14 | python-version: "3.x" 15 | cache: "pip" 16 | - name: Display Python version 17 | run: python -c "import sys; print(sys.version)" 18 | - name: Install dependencies 19 | run: | 20 | python3 -m pip install --user --upgrade pip 21 | python3 -m pip install --user Mock.GPIO 22 | python3 -m pip install --user -r requirements.txt 23 | python3 -m pip install --user -e . # So that our package can be called directly in tests, like `python -m home_automation` 24 | - name: Create empty config.txt 25 | # Otherwise when importing config.py, before any mock is created, it fails cause config.txt does not exist 26 | run: | 27 | cp config.txt.default config.txt 28 | - name: Test with pytest 29 | run: | 30 | python3 -m pytest 31 | -------------------------------------------------------------------------------- /bin/shutdown.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Inspired from: https://github.com/TonyLHansen/raspberry-pi-safe-off-switch/ 4 | 5 | from gpiozero import Button, LED 6 | from signal import pause 7 | import os 8 | import warnings 9 | 10 | # offGPIO = 3 # PIN 5 11 | offGPIO = 15 # PIN 40 12 | holdTime = 2 13 | ledGPIO = 16 # On Rpi 1 model B 14 | 15 | print("Starting shutdown.py...") 16 | 17 | 18 | # Enable trigger by GPIO 19 | # https://gpiozero.readthedocs.io/en/stable/recipes_advanced.html#controlling-the-pi-s-own-leds 20 | def when_pressed(): 21 | os.system( 22 | 'echo "Hello, friend." | mail -s "Raspberry Pi button pressed" root@localhost' 23 | ) 24 | led.blink(on_time=0.5, off_time=0.5) 25 | 26 | 27 | def when_released(): 28 | led.off() 29 | 30 | 31 | def shutdown(): 32 | print("Shutting down the Raspberry Pi...") 33 | os.system("poweroff") 34 | 35 | 36 | with warnings.catch_warnings(): 37 | warnings.simplefilter("ignore") 38 | # active_high=False is specific to Rpi 1 model B: it prevents from turning the LED on when executing this line 39 | led = LED(ledGPIO, active_high=False) 40 | 41 | btn = Button(offGPIO, hold_time=holdTime) 42 | btn.when_held = shutdown 43 | btn.when_pressed = when_pressed 44 | btn.when_released = when_released 45 | pause() # Handles the button presses in the background 46 | -------------------------------------------------------------------------------- /bin/20h.py: -------------------------------------------------------------------------------- 1 | #!/bin/env -S sh -c '"`dirname $0`/../.venv/bin/python3" "$0" "$@"' 2 | 3 | # This script can be invoked in two different ways with the same result: 4 | # $ /path/to/raspberry-pi-home-automation/bin/20h.py (thanks to the complex shebang above) 5 | # $ /path/to/raspberry-pi-home-automation/.venv/bin/python3 /path/to/raspberry-pi-home-automation/bin/20h.py 6 | 7 | import requests 8 | import re 9 | import yt_dlp 10 | from bs4 import BeautifulSoup 11 | 12 | # crontab -e 13 | # 08 21 * * * /path/to/raspberry-pi-home-automation/.venv/bin/python3 -m pip install -U --pre "yt-dlp[default]" 14 | # 10 21 * * * /path/to/raspberry-pi-home-automation/bin/20h.py 15 | 16 | url = "https://www.france.tv/france-2/journal-20h00/" 17 | 18 | if __name__ == '__main__': 19 | html_text = requests.get(url).text 20 | soup = BeautifulSoup(html_text, "html.parser") 21 | latest_video = soup.select_one("main #les-editions ul li:first-child a") 22 | 23 | if latest_video != None: 24 | video_url = re.sub(r"^/", "https://www.france.tv/", latest_video["href"]) 25 | print("Downloading " + video_url) 26 | with yt_dlp.YoutubeDL( 27 | { 28 | "outtmpl": { 29 | "default": "/var/lib/minidlna/20h-%(upload_date>%Y-%m-%d)s.%(ext)s" 30 | } 31 | } 32 | ) as ydl: 33 | ydl.download([video_url]) 34 | -------------------------------------------------------------------------------- /tests/test_turn_led_pytest.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, MagicMock 3 | 4 | MockRPi = MagicMock() 5 | modules = { 6 | "RPi": MockRPi, 7 | "RPi.GPIO": MockRPi.GPIO, 8 | } 9 | patcher = patch.dict("sys.modules", modules) 10 | patcher.start() 11 | 12 | # We need to mock RPi before importing our module 13 | import home_automation.turn_led as turn_led 14 | 15 | 16 | @patch("logging.info") 17 | class TestTurnLed(unittest.TestCase): 18 | @patch("RPi.GPIO.setup") 19 | @patch("RPi.GPIO.output") 20 | def test_turn_off(self, patched_output, patched_setup, mock_logging): 21 | turn_led.turn_off() 22 | patched_setup.assert_called_with(23, MockRPi.GPIO.OUT) 23 | patched_output.assert_called_with(23, MockRPi.GPIO.LOW) 24 | mock_logging.assert_called_with("LED off") 25 | 26 | @patch("RPi.GPIO.setup") 27 | @patch("RPi.GPIO.output") 28 | def test_turn_on(self, patched_output, patched_setup, mock_logging): 29 | turn_led.turn_on() 30 | patched_setup.assert_called_with(23, MockRPi.GPIO.OUT) 31 | patched_output.assert_called_with(23, MockRPi.GPIO.HIGH) 32 | mock_logging.assert_called_with("LED on") 33 | 34 | def test_cleanup(self, mock_logging): 35 | with patch("RPi.GPIO.cleanup") as mock_cleanup: 36 | turn_led.cleanup() 37 | mock_cleanup.assert_called_once() 38 | mock_logging.assert_called_with("LED cleaned up") 39 | -------------------------------------------------------------------------------- /strava-logger.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 39 | 40 | Strava logger 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /tests/test_config_pytest.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, mock_open 2 | import pytest 3 | import configparser 4 | from importlib import reload 5 | import home_automation.config as config 6 | 7 | mock_config_file_google_script_url_missing = """ 8 | [weatherstation] 9 | GOOGLE_SCRIPTS_URL= 10 | """ 11 | 12 | mock_config_file = """ 13 | [weatherstation] 14 | GOOGLE_SCRIPTS_URL=https://some.url 15 | 16 | [pushover] 17 | PUSHOVER_USER=456 18 | PUSHOVER_TOKEN=654 19 | """ 20 | 21 | 22 | class TestConfig: 23 | @patch("builtins.open", mock_open(read_data="")) 24 | def test_check_fails_if_file_is_empty(self): 25 | with pytest.raises(configparser.NoSectionError): 26 | reload(config) 27 | 28 | @patch("builtins.open", mock_open(read_data="[weatherstation]")) 29 | def test_check_fails_if_option_in_file_is_missing(self): 30 | with pytest.raises(configparser.NoOptionError): 31 | reload(config) 32 | 33 | @patch( 34 | "builtins.open", mock_open(read_data=mock_config_file_google_script_url_missing) 35 | ) 36 | def test_returns_default_values(self): 37 | reload(config) 38 | 39 | assert config.GOOGLE_SCRIPTS_URL == "" 40 | assert config.PUSHOVER_USER == None 41 | assert config.PUSHOVER_TOKEN == None 42 | 43 | @patch("builtins.open", mock_open(read_data=mock_config_file)) 44 | def test_check_config_contains_google_scripts_url(self): 45 | reload(config) 46 | 47 | assert config.GOOGLE_SCRIPTS_URL == "https://some.url" 48 | 49 | @patch("builtins.open", mock_open(read_data=mock_config_file)) 50 | def test_check_config_contains_pushover_credentials(self): 51 | reload(config) 52 | assert config.PUSHOVER_USER == "456" 53 | assert config.PUSHOVER_TOKEN == "654" 54 | -------------------------------------------------------------------------------- /home_automation/report_weather.py: -------------------------------------------------------------------------------- 1 | import smbus2 2 | import bme280 3 | import time 4 | import requests 5 | import redis 6 | import json 7 | import sys 8 | from .config import GOOGLE_SCRIPTS_URL 9 | 10 | port = 1 11 | address = 0x76 12 | bus = smbus2.SMBus(port) 13 | now = time.strftime("%d/%m/%Y %H:%M:%S", time.localtime()) 14 | 15 | calibration_params = bme280.load_calibration_params(bus, address) 16 | 17 | 18 | def send_request(data): 19 | try: 20 | response = requests.get( 21 | GOOGLE_SCRIPTS_URL, 22 | params={ 23 | "datetime": data["timestamp"], 24 | "temperature": data["temperature"], 25 | "humidity": data["humidity"], 26 | "pressure": data["pressure"], 27 | }, 28 | ) 29 | return response.status_code == 200 30 | except requests.exceptions.RequestException: 31 | return False 32 | 33 | 34 | def send_report(): 35 | raw_data = bme280.sample(bus, address, calibration_params) 36 | data = { 37 | "timestamp": now, 38 | "temperature": raw_data.temperature, 39 | "humidity": raw_data.humidity, 40 | "pressure": raw_data.pressure, 41 | } 42 | 43 | r = redis.Redis() 44 | r.lpush("weather_reports", json.dumps(data)) 45 | r.ltrim("weather_reports", 0, 9999) # No more than 10,000 elements stored in Redis 46 | 47 | successfully_sent = False 48 | 49 | try: 50 | successfully_sent = send_request(data) 51 | except BaseException as e: 52 | print("Error: %s" % str(e), file=sys.stderr) 53 | finally: 54 | if not successfully_sent: 55 | print("Failed to post to Google App Script", file=sys.stderr) 56 | print(data, file=sys.stderr) 57 | return successfully_sent 58 | -------------------------------------------------------------------------------- /.github/workflows/build-arduino-sketch.yml: -------------------------------------------------------------------------------- 1 | name: Build Arduino Sketch 2 | 3 | on: [push, workflow_dispatch] 4 | 5 | # This is the list of jobs that will be run concurrently. 6 | # Since we use a build matrix, the actual number of jobs 7 | # started depends on how many configurations the matrix 8 | # will produce. 9 | jobs: 10 | compile-sketch: 11 | strategy: 12 | matrix: 13 | arduino-platform: ["arduino:renesas_uno"] 14 | # This is usually optional but we need to statically define the 15 | # FQBN of the boards we want to test for each platform. In the 16 | # future the CLI might automatically detect and download the core 17 | # needed to compile against a certain FQBN, at that point the 18 | # following `include` section will be useless. 19 | include: 20 | - arduino-platform: "arduino:renesas_uno" 21 | fqbn: "arduino:renesas_uno:unor4wifi" 22 | 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | - name: Setup Arduino CLI 28 | uses: arduino/setup-arduino-cli@v1 29 | - name: Install platform and libraries 30 | run: | 31 | arduino-cli core update-index 32 | arduino-cli core install ${{ matrix.arduino-platform }} 33 | arduino-cli lib install rc-switch 34 | - name: Prepare Sketch 35 | run: | 36 | sed -i '/#define ON_SIGNAL ---REPLACE_ME---/c\#define ON_SIGNAL 123' ./ArduinoSketch/ArduinoSketch.ino 37 | sed -i '/#define OFF_SIGNAL ---REPLACE_ME---/c\#define OFF_SIGNAL 321' ./ArduinoSketch/ArduinoSketch.ino 38 | cat ./ArduinoSketch/ArduinoSketch.ino 39 | - name: Compile Sketch 40 | run: | 41 | arduino-cli compile --fqbn ${{ matrix.fqbn }} ./ArduinoSketch 42 | -------------------------------------------------------------------------------- /home_automation/remote_control.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import redis 3 | import importlib 4 | import json 5 | import sys 6 | import os 7 | from .config import GOOGLE_SCRIPTS_URL 8 | 9 | 10 | def send_request(current_value): 11 | try: 12 | response = requests.get( 13 | GOOGLE_SCRIPTS_URL, params={"remote_control": current_value} 14 | ) 15 | return (response.status_code == 200, json.loads(response.text)) 16 | except requests.exceptions.RequestException: 17 | return None 18 | 19 | 20 | def update_alarm_state(should_enable_alarm, current_alarm_state, r): 21 | new_alarm_state = current_alarm_state 22 | if should_enable_alarm == "yes": 23 | new_alarm_state = "1" 24 | elif should_enable_alarm == "no": 25 | new_alarm_state = "0" 26 | 27 | if new_alarm_state != current_alarm_state: 28 | r.set("alarm_state", new_alarm_state) 29 | # We need to update the value in the sheet again, to reflect the change of state 30 | send_request("yes" if new_alarm_state == "1" else "no") 31 | 32 | 33 | def initiate_reboot(): 34 | os.system(f'echo "As requested." | mail -s "Raspberry is rebooting" root@localhost') 35 | os.system("sudo reboot") 36 | 37 | 38 | def run(): 39 | r = redis.Redis("localhost", 6379, charset="utf-8", decode_responses=True) 40 | success = False 41 | 42 | try: 43 | alarm_state = r.get("alarm_state") 44 | success, response = send_request("yes" if alarm_state == "1" else "no") 45 | 46 | if success: 47 | should_enable_alarm = response["shouldEnableAlarm"] 48 | should_reboot = response["shouldReboot"] 49 | 50 | if should_enable_alarm != "": 51 | update_alarm_state(should_enable_alarm, alarm_state, r) 52 | 53 | if should_reboot == "yes": 54 | initiate_reboot() 55 | except BaseException as e: 56 | print("Error: %s" % str(e)) 57 | print(e) 58 | success = False 59 | 60 | if not success: 61 | print("Failed to fetch 'remote_control' from App Script", file=sys.stderr) 62 | 63 | return success 64 | -------------------------------------------------------------------------------- /home_automation/build_arduino_sketch_and_deploy.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | import os 3 | import sys 4 | from .config import get_config 5 | 6 | CONFIG = get_config() 7 | ON_SIGNAL = CONFIG.get("arduino", "RF_ON_SIGNAL", fallback=None) 8 | OFF_SIGNAL = CONFIG.get("arduino", "RF_OFF_SIGNAL", fallback=None) 9 | SCRIPT_DIRECTORY = os.path.dirname(os.path.realpath(__file__)) 10 | ARDUINO_SKETCH_FILEPATH = f"{SCRIPT_DIRECTORY}/../ArduinoSketch/ArduinoSketch.ino" 11 | 12 | 13 | def replace_in_file(filename, tuples, destination_file): 14 | with open(filename, "r") as source, open(destination_file, "w") as destination: 15 | content = source.read() 16 | for old_string, new_string in tuples: 17 | content = content.replace(old_string, new_string) 18 | destination.write(content) 19 | 20 | 21 | def destination_file_in_directory(directory=None): 22 | if directory is None: 23 | raise ValueError("No directory provided") 24 | return f"{directory}/{os.path.basename(directory)}.ino" 25 | 26 | 27 | def build_and_deploy(): 28 | if (not ON_SIGNAL) or (not OFF_SIGNAL): 29 | print("Please set RF_ON_SIGNAL & RF_OFF_SIGNAL in config.txt", file=sys.stderr) 30 | sys.exit(1) 31 | 32 | # After this `with` block, directory `destination_directory` will be automatically deleted. 33 | with tempfile.TemporaryDirectory() as destination_directory, open( 34 | destination_file_in_directory(destination_directory), "x" 35 | ) as destination_file: 36 | replace_in_file( 37 | ARDUINO_SKETCH_FILEPATH, 38 | [ 39 | ( 40 | "#define ON_SIGNAL ---REPLACE_ME---", 41 | f"#define ON_SIGNAL {ON_SIGNAL}", 42 | ), 43 | ( 44 | "#define OFF_SIGNAL ---REPLACE_ME---", 45 | f"#define OFF_SIGNAL {OFF_SIGNAL}", 46 | ), 47 | ], 48 | destination_file.name, 49 | ) 50 | print(f"Compiling {destination_file.name}...") 51 | 52 | os.system("sudo systemctl stop door-sensor") 53 | os.system( 54 | f'arduino-cli compile --fqbn arduino:renesas_uno:unor4wifi "{destination_directory}" && arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:renesas_uno:unor4wifi "{destination_directory}"' 55 | ) 56 | os.system("sudo systemctl start door-sensor") 57 | 58 | return True 59 | -------------------------------------------------------------------------------- /ArduinoSketch/ArduinoSketch.ino: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | // To deploy: 4 | // arduino-cli compile --fqbn arduino:renesas_uno:unor4wifi path/to/raspberry-pi-home-automation/ArduinoSketch/ 5 | // arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:renesas_uno:unor4wifi path/to/raspberry-pi-home-automation/ArduinoSketch/ 6 | // To monitor: arduino-cli monitor -p /dev/ttyACM0. This will interrupt the connection any other process might have 7 | // with the Arduino (door-sensor.py for instance). 8 | 9 | RCSwitch mySwitch = RCSwitch(); 10 | 11 | // The RF receiver must be connected from VCC to 5V, GND to GND, and any of the two remaining pins to pin #2 on the Arduino, the 4th pin being useless. 12 | // The PIR sensor must be connected from VCC to 5V, GND to GND, D1 to pin #3 on the Arduino, the 4th pin being useless. 13 | 14 | #define RF_RECEIVER 2 15 | #define PIR_MOTION_SENSOR 3 16 | #define BUZZER 4 17 | 18 | #define ON_SIGNAL ---REPLACE_ME--- 19 | #define OFF_SIGNAL ---REPLACE_ME--- 20 | 21 | void setup() { 22 | Serial.begin(9600); // To enable writing logs. As a side effect, this allows communication with the Raspberry Pi. 23 | mySwitch.enableReceive(RF_RECEIVER); 24 | pinMode(PIR_MOTION_SENSOR, INPUT); 25 | pinMode(BUZZER, OUTPUT); 26 | } 27 | 28 | void playOnSound() { 29 | digitalWrite(BUZZER, HIGH); // digitalWrite() for active buzzers, tone() for passive ones 30 | delay(100); 31 | digitalWrite(BUZZER, LOW); 32 | delay(100); 33 | digitalWrite(BUZZER, HIGH); 34 | delay(100); 35 | digitalWrite(BUZZER, LOW); 36 | } 37 | 38 | void playOffSound() { 39 | digitalWrite(BUZZER, HIGH); 40 | delay(300); 41 | digitalWrite(BUZZER, LOW); 42 | } 43 | 44 | // We do not want to systematically send detected motion to the Raspberry Pi, when the alarm is disengaged, because 45 | // otherwise we would send too many "Motion detected" events to the Raspberry Pi, and the Pi would take time to process 46 | // them all, delaying the processing of other events such as "ON pressed" or "OFF pressed". 47 | bool shouldReportDetectedMotion = true; 48 | 49 | void readInputFromRaspberryPi() { 50 | if (Serial.available() > 0) { 51 | String data = Serial.readStringUntil('\n'); 52 | 53 | if (data == "play_on_sound") { 54 | playOnSound(); 55 | } 56 | if (data == "disarm_alarm") { 57 | playOffSound(); 58 | shouldReportDetectedMotion = false; 59 | } 60 | if (data == "arm_alarm") { 61 | shouldReportDetectedMotion = true; 62 | } 63 | } 64 | } 65 | 66 | void loop() { 67 | readInputFromRaspberryPi(); 68 | 69 | if (mySwitch.available()) { // If we received a RF signal 70 | int value = mySwitch.getReceivedValue(); 71 | 72 | if (value == ON_SIGNAL) { 73 | Serial.println("ON pressed"); 74 | } 75 | else if (value == OFF_SIGNAL) { 76 | Serial.println("OFF pressed"); 77 | } 78 | else { 79 | Serial.println("Unknown message received: " + String(value)); 80 | } 81 | 82 | mySwitch.resetAvailable(); 83 | } 84 | else { 85 | // Serial.println("No message received"); 86 | } 87 | 88 | if (digitalRead(PIR_MOTION_SENSOR) && shouldReportDetectedMotion) { 89 | Serial.println("Motion detected"); 90 | } 91 | else { 92 | // Serial.println("No motion"); 93 | } 94 | 95 | delay(50); 96 | } 97 | -------------------------------------------------------------------------------- /docs/report_weather.README.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | 1. Create a Google Spreadsheet, give the main sheet the title "Raw data". 4 | 1. From that Google Spreadsheet, create a linked Google App Script, whose `Code.gs` file must contain: 5 | 6 | ```javascript 7 | const sheetId = "ABC"; // Spreadsheet id, replace with yours 8 | 9 | const MAX_ROW_NUMBER = 39500; 10 | 11 | function getRemoteControl(currentValue = "") { 12 | const sheet = 13 | SpreadsheetApp.openById(sheetId).getSheetByName("Door status"); 14 | 15 | const shouldEnableAlarmCell = sheet.getRange("F1"); 16 | const shouldEnableAlarm = shouldEnableAlarmCell.getValue().toLowerCase(); 17 | if (shouldEnableAlarm !== "") shouldEnableAlarmCell.setValue(""); 18 | 19 | sheet 20 | .getRange("F5") 21 | .setValue( 22 | currentValue !== "" ? currentValue : "unknown (value not received)" 23 | ); 24 | 25 | const shouldRebootCell = sheet.getRange("F2"); 26 | const shouldReboot = shouldRebootCell.getValue().toLowerCase(); 27 | if (shouldReboot !== "") shouldRebootCell.setValue(""); 28 | 29 | sheet.getRange("F6").setValue(new Date().toString()); 30 | 31 | return JSON.stringify({ shouldEnableAlarm, shouldReboot }); 32 | } 33 | 34 | function doGet(e) { 35 | const result = JSON.stringify(e); // assume success 36 | 37 | if (e.parameter == undefined || Object.keys(e.parameter).length === 0) { 38 | result = "no params given"; 39 | } else { 40 | const rowData = []; 41 | for (var param in e.parameter) { 42 | Logger.log("In for loop, param=" + param); 43 | const value = e.parameter[param]; 44 | switch (param) { 45 | case "datetime": 46 | rowData[1] = value; 47 | break; 48 | case "temperature": 49 | rowData[2] = value; 50 | break; 51 | case "humidity": 52 | rowData[3] = value; 53 | break; 54 | case "pressure": 55 | rowData[4] = value; 56 | break; 57 | case "door_status": 58 | rowData[5] = value; 59 | break; 60 | case "remote_control": 61 | result = getRemoteControl(value); 62 | return ContentService.createTextOutput(result); 63 | default: 64 | result = "failed"; 65 | return ContentService.createTextOutput(result); 66 | } 67 | } 68 | const now = new Date(); 69 | const isoDateTimeInCurrentTimezone = new Date(now.getTime() - (now.getTimezoneOffset() * 60000)).toISOString().replace(/T/, ' ').replace(/\..+/, ''); 70 | rowData[0] = isoDateTimeInCurrentTimezone; // "2024-09-21 18:04:12" 71 | Logger.log(JSON.stringify(rowData)); 72 | 73 | const lock = LockService.getScriptLock(); 74 | const success = lock.tryLock(30000); // 30 secs 75 | if (!success) { 76 | Logger.log('Could not obtain lock after 30 seconds. Proceeding anyways... despite the risk of overwriting the current last line in the sheet.'); 77 | } 78 | 79 | const sheet = 80 | SpreadsheetApp.openById(sheetId).getSheetByName("Raw data"); 81 | 82 | while (sheet.getLastRow() >= MAX_ROW_NUMBER) { 83 | sheet.deleteRow(2); // Deletes the second row (the one below the headers) 84 | } 85 | 86 | const newRow = sheet.getLastRow() + 1; 87 | 88 | // Write new row to spreadsheet 89 | var newRange = sheet.getRange(newRow, 1, 1, rowData.length); 90 | newRange.setValues([rowData]); 91 | } 92 | 93 | return ContentService.createTextOutput(result); 94 | } 95 | ``` 96 | 97 | 1. Deploy your script, copy the URL 98 | 1. Make sure it works locally by invoking: 99 | 100 | ```bash 101 | curl -L https://script.google.com/macros/s/XYZ/exec\?temperature\=20\&humidity\=50 102 | ``` 103 | 104 | 1. On the Raspberry Pi, close this repo, install redis and set up the Python env from the root of this repo: 105 | 106 | ```bash 107 | sudo apt install redis-server 108 | python3 -m venv .venv 109 | source .venv/bin/activate 110 | pip3 install -r requirements.txt 111 | 112 | ``` 113 | 114 | 1. In `weatherstation.py`, change the URL for your valid Google Script URL. 115 | 1. Run `sudo raspi-config`, in `3 Interface Options`, enable `I2C`. 116 | 1. Create a cronjob: `*/3 * * * * /path/to/raspberry-pi-home-automation/.venv/bin/python /path/to/raspberry-pi-home-automation/temperature/weatherstation.py` 117 | -------------------------------------------------------------------------------- /bin/auto-mute-strava-activities.py: -------------------------------------------------------------------------------- 1 | #!/bin/env -S sh -c '"`dirname $0`/../.venv/bin/python3" "$0" "$@"' 2 | 3 | # This script can be invoked in two different ways with the same result: 4 | # $ /path/to/raspberry-pi-home-automation/bin/auto-mute-strava-activities.py (thanks to the complex shebang above) 5 | # $ /path/to/raspberry-pi-home-automation/.venv/bin/python3 /path/to/raspberry-pi-home-automation/bin/auto-mute-strava-activities.py 6 | 7 | import requests 8 | import os 9 | import sys 10 | import json 11 | import time 12 | import redis 13 | import threading 14 | from http.server import HTTPServer, SimpleHTTPRequestHandler 15 | 16 | # HOW TO USE WITH CRON: 17 | # 0 */1 * * * DRY_RUN=0 CLIENT_ID=123 CLIENT_SECRET="abc456" REFRESH_TOKEN=xyz /path/to/raspberry-pi-home-automation/bin/auto-mute-strava-activities.py 18 | 19 | # A token can be obtained by running this script without the `REFRESH_TOKEN`` env variable. 20 | REFRESH_TOKEN = os.getenv("REFRESH_TOKEN") 21 | CLIENT_ID = os.environ.get("CLIENT_ID") 22 | CLIENT_SECRET = os.environ.get("CLIENT_SECRET") 23 | DRY_RUN = bool(int(os.environ.get("DRY_RUN") or "1")) 24 | DO_NOT_HIDE_RUNS = bool(int(os.environ.get("DO_NOT_HIDE_RUNS") or "0")) 25 | 26 | if (CLIENT_ID == None) or (CLIENT_SECRET == None): 27 | print("Please set CLIENT_ID and CLIENT_SECRET") 28 | sys.exit(1) 29 | 30 | 31 | class StoppableHTTPServer(HTTPServer): 32 | def run(self): 33 | try: 34 | self.serve_forever() 35 | except KeyboardInterrupt: 36 | pass 37 | finally: 38 | # Clean-up server (close socket, etc.) 39 | self.server_close() 40 | 41 | 42 | if REFRESH_TOKEN == None: 43 | server = StoppableHTTPServer(("localhost", 8080), SimpleHTTPRequestHandler) 44 | t = threading.Thread(target=server.run) 45 | t.start() 46 | print( 47 | f"Now visit https://www.strava.com/oauth/authorize?client_id={CLIENT_ID}&response_type=code&redirect_uri=http://localhost:8080/strava-logger.html%3Fclient_creds%3D{CLIENT_ID}_{CLIENT_SECRET}&approval_prompt=auto&scope=read,activity:read_all,activity:write" 48 | ) 49 | try: 50 | input("Hit Enter when done\n") 51 | except KeyboardInterrupt: 52 | server.shutdown() 53 | print("Exiting") 54 | t.join() 55 | sys.exit(1) 56 | 57 | server.shutdown() 58 | t.join() 59 | print( 60 | "Now you know your refresh token. Relaunch this script with REFRESH_TOKEN=xyz as an env variable." 61 | ) 62 | sys.exit(0) 63 | 64 | epoch_time = int(time.time()) 65 | epoch_time_one_week_ago = epoch_time - (3600 * 24 * 7) 66 | 67 | auth_url = "https://www.strava.com/api/v3/oauth/token" 68 | activities_url = f"https://www.strava.com/api/v3/athlete/activities?per_page=100&after={epoch_time_one_week_ago}" 69 | 70 | json_headers = {"Content-Type": "application/json"} 71 | 72 | REDIS_INSTANCE = redis.Redis("localhost", 6379, charset="utf-8", decode_responses=True) 73 | 74 | SAVED_REFRESH_TOKEN = REDIS_INSTANCE.get(REFRESH_TOKEN) 75 | 76 | auth_payload = { 77 | "refresh_token": (SAVED_REFRESH_TOKEN or REFRESH_TOKEN), 78 | "client_id": CLIENT_ID, 79 | "client_secret": CLIENT_SECRET, 80 | "grant_type": "refresh_token", 81 | } 82 | 83 | r = requests.post(auth_url, headers=json_headers, data=json.dumps(auth_payload)) 84 | r.raise_for_status() 85 | response = json.loads(r.text) 86 | ACCESS_TOKEN = response["access_token"] 87 | RECEIVED_REFRESH_TOKEN = response["refresh_token"] 88 | 89 | REDIS_INSTANCE.set(REFRESH_TOKEN, RECEIVED_REFRESH_TOKEN) 90 | 91 | authenticated_headers = json_headers | {"Authorization": f"Bearer {ACCESS_TOKEN}"} 92 | 93 | r = requests.get(activities_url, headers=authenticated_headers) 94 | r.raise_for_status() 95 | activities = json.loads(r.text) 96 | 97 | 98 | def was_not_yet_processed(activity): 99 | activity_id = activity["id"] 100 | 101 | if REDIS_INSTANCE.get(f"strava_activity_{activity_id}") != None: 102 | print(f"Skipping activity {activity_id} as it was already processed") 103 | return False 104 | 105 | return True 106 | 107 | 108 | def should_be_muted(activity): 109 | sport_type = activity["sport_type"] 110 | distance = activity["distance"] 111 | hide_from_home = ("hide_from_home" in activity) and (activity["hide_from_home"]) 112 | # at the moment, `hide_from_home` is never present, even when true, while the doc says it should be there... 113 | 114 | return ( 115 | (sport_type == "Walk") 116 | or (sport_type == "Ride" and distance < 10_000.0) 117 | or ((not DO_NOT_HIDE_RUNS) and (sport_type == "Run" and distance < 15_000.0)) 118 | ) and (not hide_from_home) 119 | 120 | 121 | # sport_type, distance, hide_from_home 122 | print(f"Received in total {len(activities)} activities") 123 | activities_non_processed = list(filter(was_not_yet_processed, activities)) 124 | 125 | activites_to_mute = list(filter(should_be_muted, activities_non_processed)) 126 | 127 | EXPIRATION_IN_ONE_YEAR = 60 * 60 * 24 * 365 128 | 129 | 130 | def process_activities(activities, payload, log_message): 131 | for i, activity in enumerate(activities): 132 | activity_id = activity["id"] 133 | print( 134 | f"{i+1:02d}/{len(activities)}: https://www.strava.com/activities/{activity_id} marked as {log_message}" 135 | ) 136 | 137 | if not DRY_RUN: 138 | activity_url = f"https://www.strava.com/api/v3/activities/{activity_id}" 139 | r = requests.put( 140 | activity_url, headers=authenticated_headers, data=json.dumps(payload) 141 | ) 142 | r.raise_for_status() 143 | REDIS_INSTANCE.set( 144 | f"strava_activity_{activity_id}", 1, EXPIRATION_IN_ONE_YEAR 145 | ) 146 | print( 147 | f"Marked https://www.strava.com/activities/{activity_id} as processed" 148 | ) 149 | else: 150 | print("Dry run. No effect.") 151 | 152 | 153 | process_activities(activites_to_mute, {"hide_from_home": True}, "hidden") 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Setting up the Raspberry Pi 2 | 3 | ![GPIO pins](docs/GPIO.png) 4 | 5 | # Setup 6 | 7 | After installing a fresh version of Raspbian, and cloning this repo, follow [the beginning of a tutorial I have written](https://romainpellerin.eu/raspberry-pi-the-ultimate-guide.html) to set up the Pi. Do not set `max_usb_current=1` if the power supply cannot output more than 1A. When running `raspi-config`, make sure to: 8 | 9 | - Enable the camera. First make sure it works by running `libcamera-still -o test.jpg`. 10 | - Give the GPU at least 128MB (more is recommended, apparently) 11 | 12 | You can stop reading the tutorial at the end of the section "Configuration". 13 | 14 | ## Crontab 15 | 16 | ```bash 17 | crontab -e 18 | # Daily cleaning of /var/lib/minidlna and /tmp (videos and pictures from the webcam). Durations are in days (`mtime`). 19 | 0 21 * * * find /var/lib/minidlna -type f -mtime +2 -delete 20 | 0 21 * * * find /var/lib/minidlna -type d -mtime +2 -empty -delete 21 | 0 21 * * * find /tmp -type f -iname '*.mp4' -mtime +90 -delete 22 | 0 21 * * * find /tmp -type f -iname '*.h264' -mtime +90 -delete 23 | 0 21 * * * find /tmp -type f -iname '*.jpg' -mtime +90 -delete 24 | 25 | # Periodic update of Strava activities 26 | 0 */1 * * * DRY_RUN=0 CLIENT_ID=123 CLIENT_SECRET="abc456" REFRESH_TOKEN=xyz /path/to/raspberry-pi-home-automation/auto-mute-strava-activities.py 2>&1 | /usr/bin/logger -t STRAVA 27 | 28 | # Periodic reporting of temperature 29 | */3 * * * * /path/to/raspberry-pi-home-automation/.venv/bin/python3 -m home_automation report_weather 30 | 31 | # Remote control of the Raspberry Pi 32 | */2 * * * * /path/to/raspberry-pi-home-automation/.venv/bin/python3 -m home_automation remote_control 33 | 34 | # French and German news. There are antislashes before the % signs, cause % signs have a special meaning for cron. 35 | 08 21 * * * /path/to/raspberry-pi-home-automation/.venv/bin/python3 -m pip install -U --pre "yt-dlp[default]" 36 | 10 21 * * * /path/to/raspberry-pi-home-automation/bin/20h.py 37 | 15 22 * * * /path/to/raspberry-pi-home-automation/.venv/bin/yt-dlp https://www.ardmediathek.de/sendung/tagesschau/Y3JpZDovL2Rhc2Vyc3RlLmRlL3RhZ2Vzc2NoYXU -I 1 -o "/var/lib/minidlna/\%(title)s.\%(ext)s" --embed-subs 38 | # Removing the subtitles file from German news 39 | 30 22 * * * find /var/lib/minidlna -type f -iname '*.vtt' -delete 40 | 41 | sudo su 42 | crontab -e 43 | @reboot /bin/sleep 20; /usr/sbin/exim -qff; echo "So you know... ($(/bin/date))\n\n$(/usr/bin/tail -n 500 /var/log/syslog)" | mail -s "Rpi turned on 20secs ago" root 44 | ``` 45 | 46 | ## Rest of the setup 47 | 48 | Now make sure the camera is correctly detected: 49 | 50 | ```bash 51 | sudo vcgencmd get_camera 52 | ``` 53 | 54 | ```bash 55 | sudo apt install python3-gpiozero redis-server python3-picamera ffmpeg libatlas-base-dev python3-picamera2 python3-opencv 56 | # TODO: remove python3-picamera, cause replaced by python3-picamera2? 57 | 58 | cd /to/the/cloned/repo 59 | 60 | python3 -m venv --system-site-packages .venv # --system-site-packages to have the system-installed picamera2 module available 61 | source .venv/bin/activate 62 | pip3 install -r requirements.txt 63 | pip3 install Mock.GPIO # To be able to run tests locally 64 | pip3 install -e . # So that our package can be called directly from the CLI, like `python -m home_automation` 65 | 66 | /path/to/raspberry-pi-home-automation/.venv/bin/python3 -m home_automation build_arduino_sketch_and_deploy 67 | 68 | sudo cp \ 69 | systemd-services/shutdown.service \ 70 | systemd-services/door-sensor.service \ 71 | systemd-services/video-recorder.service \ 72 | /etc/systemd/system 73 | 74 | sudo systemctl enable shutdown.service 75 | sudo systemctl enable door-sensor.service 76 | sudo systemctl enable video-recorder.service 77 | 78 | sudo systemctl daemon-reload 79 | 80 | sudo systemctl start shutdown.service 81 | sudo systemctl start door-sensor.service 82 | sudo systemctl start video-recorder.service 83 | ``` 84 | 85 | # Temperature, humidity, pressure 86 | 87 | Head over to [report_weather.README.md](docs/report_weather.README.md). 88 | 89 | # MiniDLNA 90 | 91 | ## Foreword 92 | 93 | MiniDLNA is the former name. The project is now called ReadyMedia. You can find it [here](https://sourceforge.net/projects/minidlna/). 94 | 95 | ## Install 96 | 97 | You may install it through `apt` but you might get an old version (check with `sudo apt search minidlna`). In my case, as of February 2023, I would have gotten version 1.3.0 through `apt`, while 1.3.2 is already out, with `.webm` supported freshly added. So I decided to compile it myself from upstream. 98 | 99 | ### Manual install 100 | 101 | ```bash 102 | # Prerequisite 103 | sudo apt install build-essential gettext 104 | # gettext is needed on Raspberry Pi: 105 | # reddit.com/r/raspberry_pi/comments/9qq3y5/readymedia_12x_fails_with_cannot_stat_tdagmo_no/ 106 | 107 | # Download 108 | wget https://nav.dl.sourceforge.net/project/minidlna/minidlna/1.3.2/minidlna-1.3.2.tar.gz 109 | tar -xvf minidlna-1.3.2.tar.gz 110 | cd minidlna-1.3.2 111 | 112 | # Compile 113 | ./configure 114 | # Based on errors you got with the above-mentioned command, you'll have to 115 | # sudo apt install libavutil-dev libavcodec-dev libavformat-dev libjpeg-dev libsqlite3-dev libexif-dev \ 116 | # libid3tag0-dev libogg-dev libvorbis-dev libflac-dev 117 | # and relaunch ./configure 118 | make 119 | 120 | #Install 121 | sudo make install 122 | 123 | sudo mkdir /var/cache/minidlna 124 | sudo mkdir /var/lib/minidlna 125 | sudo chown minidlna:minidlna /var/cache/minidlna 126 | sudo chown minidlna:minidlna /var/lib/minidlna 127 | sudo chmod -R o+rX /var/lib/minidlna 128 | 129 | # Configure 130 | sudo cp minidlna.conf /etc/ 131 | sudo vim /etc/minidlna.conf 132 | # friendly_name=some_nicer_name_than_the_default_hostname 133 | # log_dir=/var/log 134 | # media_dir=/var/lib/minidlna 135 | 136 | # Now copy the service from this repo and start it 137 | sudo cp systemd-services/minidlna.service /etc/systemd/system 138 | sudo systemctl enable minidlna.service 139 | sudo systemctl daemon-reload 140 | sudo systemctl start minidlna.service 141 | ``` 142 | 143 | # Further reading 144 | 145 | ## Fine tuning when using a SD card only (no external SDD) 146 | 147 | ```bash 148 | sudo tune2fs -c -1 -i 0 /dev/mmcblk0p2 # no check when booting 149 | sudo tune2fs -O ^has_journal /dev/mmcblk0p2 # no journalling, must be done from a PC on mmcblk0p2 unmounted 150 | ``` 151 | 152 | In `/etc/fstab`: 153 | 154 | ```bash 155 | /dev/mmcblk0p2 / ext4 defaults,noatime 0 0 # final zero means never run fsck 156 | tmpfs /tmp tmpfs defaults,noatime,size=34m 0 0 157 | tmpfs /var/log tmpfs defaults,noatime,size=30m 0 0 158 | ``` 159 | 160 | Also disable swaping to extend your SD card lifetime: 161 | 162 | ```bash 163 | sudo swapoff --all # Temporary 164 | sudo update-rc.d -f dphys-swapfile remove 165 | sudo apt remove dphys-swapfile # Permanently 166 | sudo rm /var/swap 167 | ``` 168 | 169 | ## Links 170 | 171 | - [Smarten up your Pi Zero Web Camera with Image Analysis and Amazon Web Services (Part 1)](https://www.bouvet.no/bouvet-deler/utbrudd/smarten-up-your-pi-zero-web-camera-with-image-analysis-and-amazon-web-services-part-1) 172 | - [Limit the runtime of a cronjob or script](https://ma.ttias.be/limit-runtime-cronjob-script/) 173 | - [A Guide to Recording 660FPS Video On A $6 Raspberry Pi Camera](http://blog.robertelder.org/recording-660-fps-on-raspberry-pi-camera/) 174 | - [Xiaomi Miijia LYWSD03MMC with pure bluetoothctl](https://false.ekta.is/2021/06/xiaomi-miijia-lywsd03mmc-with-pure-bluetoothctl/) 175 | - [Multiple cameras with the Raspberry Pi and OpenCV](https://pyimagesearch.com/2016/01/18/multiple-cameras-with-the-raspberry-pi-and-opencv/) 176 | - [Making my own NVR — with a streaming proxy and FFmpeg](https://blog.cavelab.dev/2024/01/diy-nvr-ffmpeg/) 177 | - [Making a manual security alarm in Home Assistant](https://blog.cavelab.dev/2021/11/home-assistant-manual-alarm/) 178 | - [Raspberry Pi security alarm — the basics](https://blog.cavelab.dev/2022/12/rpi-security-alarm/) 179 | -------------------------------------------------------------------------------- /video_recorder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import requests 5 | import redis 6 | import signal 7 | import sys 8 | import time 9 | import os 10 | import logging 11 | import subprocess 12 | import importlib 13 | import threading 14 | 15 | from picamera2.encoders import H264Encoder # python3-picamera2 16 | from picamera2.outputs import CircularOutput # python3-picamera2 17 | from picamera2 import Picamera2, MappedArray # python3-picamera2 18 | from libcamera import Transform 19 | from libcamera import controls 20 | 21 | import cv2 # python3-opencv 22 | 23 | import home_automation.turn_led as turn_led 24 | from home_automation.config import PUSHOVER_USER 25 | from home_automation.config import PUSHOVER_TOKEN 26 | 27 | REPO_PATH = os.path.join(os.path.dirname(__file__)) 28 | SEND_EMAIL_SCRIPT_PATH = os.path.join(REPO_PATH, "send-email.sh") 29 | 30 | format_logs = "%(asctime)s: %(message)s" 31 | logging.basicConfig(stream=sys.stdout, format=format_logs, level=logging.INFO) 32 | 33 | fps = 10 34 | 35 | thread = None 36 | pubsub = None 37 | 38 | # Constants for displaying the timestamp on the video 39 | colour = (0, 255, 0) 40 | origin = (0, 30) 41 | font = cv2.FONT_HERSHEY_SIMPLEX 42 | scale = 1 43 | thickness = 2 44 | 45 | picam2 = Picamera2() 46 | 47 | PUSHOVER_URL = "https://api.pushover.net/1/messages.json" 48 | 49 | 50 | def post_to_pushover(message, attachment): 51 | if (PUSHOVER_USER == None) or (PUSHOVER_TOKEN == None): 52 | logging.warn("Missing env vars to send to Pushover") 53 | return 54 | 55 | payload = { 56 | "user": PUSHOVER_USER, 57 | "token": PUSHOVER_TOKEN, 58 | "message": message, 59 | "priority": 1, # High priority 60 | } 61 | 62 | try: 63 | if (attachment) and (attachment.endswith(".jpg")): 64 | response = requests.post( 65 | PUSHOVER_URL, 66 | data=payload, 67 | files={ 68 | "attachment": ( 69 | "image.jpg", 70 | open(attachment, "rb"), 71 | "image/jpeg", 72 | ) 73 | }, 74 | ) 75 | else: 76 | response = requests.post(PUSHOVER_URL, data=payload) 77 | return response.status_code == 200 78 | except requests.exceptions.RequestException: 79 | logging.error("RequestException!") 80 | return False 81 | except BaseException as error: 82 | logging.error("BaseException!") 83 | logging.error(error) 84 | return False 85 | 86 | 87 | def post_message(message, push_notification_too, attachment=""): 88 | logging.info("Sending to Pushover") 89 | # subprocess.Popen is non blocking 90 | subprocess.Popen([SEND_EMAIL_SCRIPT_PATH, message, attachment]) 91 | logging.info("Sending over email") 92 | if push_notification_too: 93 | threading.Thread(target=post_to_pushover, args=[message, attachment]).start() 94 | logging.info("Done sending") 95 | 96 | 97 | def apply_timestamp(request): 98 | timestamp = time.strftime("%Y-%m-%d %X") 99 | with MappedArray(request, "main") as m: 100 | cv2.putText(m.array, timestamp, origin, font, scale, colour, thickness) 101 | 102 | 103 | def signal_handler(sig, frame): 104 | logging.info("You pressed Ctrl+C!") 105 | logging.info("Gracefully exiting...") 106 | if thread != None: 107 | thread.stop() 108 | thread.join(timeout=1.0) 109 | pubsub.close() 110 | 111 | turn_led.cleanup() 112 | picam2.stop_encoder() 113 | logging.info("Encoder stopped") 114 | picam2.stop() 115 | logging.info("Camera stoppped") 116 | 117 | 118 | red = redis.Redis("localhost", 6379, charset="utf-8", decode_responses=True) 119 | 120 | size = (1280, 720) 121 | 122 | conf = picam2.create_video_configuration( 123 | main={"size": size, "format": "RGB888"}, 124 | transform=Transform(hflip=True, vflip=True), 125 | controls={ 126 | "FrameRate": float(fps), 127 | "AfMode": controls.AfModeEnum.Continuous, 128 | "NoiseReductionMode": controls.draft.NoiseReductionModeEnum.Off, 129 | }, 130 | ) 131 | picam2.configure(conf) 132 | picam2.pre_callback = apply_timestamp 133 | one_mega_bits_per_second = 1_000_000 134 | encoder = H264Encoder( 135 | one_mega_bits_per_second, 136 | repeat=True, 137 | framerate=float(fps), 138 | enable_sps_framerate=True, 139 | ) 140 | duration = 5 # seconds 141 | encoder.output = CircularOutput(buffersize=int(fps * (duration + 0.2))) 142 | picam2.encoder = encoder 143 | picam2.start() # Start the cam only 144 | picam2.start_encoder() 145 | 146 | 147 | def alarm_state(): 148 | return red.get("alarm_state") == "1" 149 | 150 | 151 | def door_status_change(message): 152 | door_status = message["data"] 153 | logging.info("Door status received: " + door_status) 154 | if door_status == "open" or door_status == "still_open" or door_status == "motion": 155 | now = time.strftime("%Y-%m-%dT%H:%M:%S") 156 | 157 | alarm_enabled = alarm_state() 158 | 159 | if alarm_enabled: 160 | if door_status == "open": 161 | post_message("Door just opened!", push_notification_too=True) 162 | elif door_status == "still_open": 163 | post_message("Door still open", push_notification_too=True) 164 | elif door_status == "motion": 165 | post_message("Motion detected", push_notification_too=True) 166 | else: 167 | post_message( 168 | f"Alert! Received: {door_status}", push_notification_too=True 169 | ) 170 | 171 | turn_led.turn_on() 172 | 173 | photo1 = f"/tmp/{now}-1.jpg" 174 | picam2.capture_file(photo1) 175 | if alarm_enabled: 176 | post_message( 177 | "Alarm - photo 1", push_notification_too=True, attachment=photo1 178 | ) 179 | 180 | logging.info("Recording...") 181 | filename = f"/tmp/{now}.h264" 182 | encoder.output.fileoutput = filename 183 | encoder.output.start() # TODO: this line (or the one 4 lines below) can crash sometimes. Find a way to recover. 184 | 185 | time.sleep(5) # 5 seconds of video this far 186 | photo2 = f"/tmp/{now}-2.jpg" 187 | logging.info("Taking photo 2...") 188 | picam2.capture_file(photo2) # TODO: this line can crash sometimes. Find a way to recover. 189 | logging.info("Done") 190 | 191 | if alarm_enabled: 192 | post_message( 193 | "Alarm - photo 2", push_notification_too=True, attachment=photo2 194 | ) 195 | 196 | time.sleep(5) # 10 seconds of video this far 197 | photo3 = f"/tmp/{now}-3.jpg" 198 | picam2.capture_file(photo3) 199 | if alarm_enabled: 200 | post_message( 201 | "Alarm - photo 3", push_notification_too=True, attachment=photo3 202 | ) 203 | 204 | time.sleep(5) # 15 seconds of video this far 205 | 206 | encoder.output.stop() 207 | turn_led.turn_off() 208 | logging.info("Done recording") 209 | final_filename = f"{filename}.mp4" 210 | os.system(f"ffmpeg -r {fps} -i {filename} -vcodec copy {final_filename}") 211 | if alarm_enabled: 212 | post_message( 213 | "Alarm - video", push_notification_too=False, attachment=final_filename 214 | ) 215 | logging.info("Ffmpeg done") 216 | 217 | 218 | if __name__ == "__main__": 219 | signal.signal(signal.SIGINT, signal_handler) 220 | signal.signal(signal.SIGTERM, signal_handler) 221 | pubsub = red.pubsub() 222 | pubsub.subscribe(**{"door_status": door_status_change}) 223 | thread = pubsub.run_in_thread(sleep_time=0.001) 224 | logging.info(f"Script is located in {REPO_PATH}") 225 | logging.info(subprocess.check_output("df -h", shell=True).decode("utf-8")) 226 | logging.info("Awaiting order to record video...") 227 | -------------------------------------------------------------------------------- /tests/test_build_arduino_sketch_and_deploy_pytest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tempfile 3 | from unittest.mock import patch, mock_open, ANY 4 | from importlib import reload 5 | import home_automation.build_arduino_sketch_and_deploy as build_arduino_sketch_and_deploy 6 | 7 | mock_config_file_with_missing_values = """ 8 | [weatherstation] 9 | GOOGLE_SCRIPTS_URL=https://some.url 10 | 11 | [arduino] 12 | RF_ON_SIGNAL= 13 | RF_OFF_SIGNAL= 14 | """ 15 | 16 | mock_config_file = """ 17 | [weatherstation] 18 | GOOGLE_SCRIPTS_URL=https://some.url 19 | 20 | [arduino] 21 | RF_ON_SIGNAL=123123 22 | RF_OFF_SIGNAL=321321 23 | """ 24 | 25 | 26 | class MockOpenForConfigOnly: 27 | builtin_open = open 28 | 29 | def __init__(self, read_data): 30 | self.read_data = read_data 31 | 32 | def open(self, *args, **kwargs): 33 | if args[0].endswith("/config.txt"): 34 | return mock_open(read_data=self.read_data)(*args, **kwargs) 35 | return self.builtin_open(*args, **kwargs) 36 | 37 | 38 | class TestBuildArduinoSketchAndDeploy: 39 | @patch("builtins.open", mock_open(read_data="")) 40 | def test_fails_without_any_configuration(self): 41 | reload(build_arduino_sketch_and_deploy) 42 | with pytest.raises(SystemExit): 43 | build_arduino_sketch_and_deploy.build_and_deploy() 44 | 45 | @patch("builtins.open", mock_open(read_data="[arduinooo]")) 46 | def test_fails_without_the_right_configuration(self): 47 | reload(build_arduino_sketch_and_deploy) 48 | with pytest.raises(SystemExit): 49 | build_arduino_sketch_and_deploy.build_and_deploy() 50 | 51 | @patch("builtins.open", mock_open(read_data=mock_config_file_with_missing_values)) 52 | def test_fails_with_missing_values(self): 53 | reload(build_arduino_sketch_and_deploy) 54 | with pytest.raises(SystemExit): 55 | build_arduino_sketch_and_deploy.build_and_deploy() 56 | 57 | def test_destination_file_in_directory(self): 58 | with pytest.raises(ValueError): 59 | build_arduino_sketch_and_deploy.destination_file_in_directory() 60 | 61 | assert ( 62 | "/tmp/yolo/yolo.ino" 63 | == build_arduino_sketch_and_deploy.destination_file_in_directory( 64 | "/tmp/yolo" 65 | ) 66 | ) 67 | 68 | @patch("builtins.open", MockOpenForConfigOnly(read_data=mock_config_file).open) 69 | def test_replace_in_file(self): 70 | reload(build_arduino_sketch_and_deploy) 71 | with tempfile.TemporaryDirectory() as destination_directory, open( 72 | build_arduino_sketch_and_deploy.destination_file_in_directory( 73 | destination_directory 74 | ), 75 | "x", 76 | ) as destination_file: 77 | build_arduino_sketch_and_deploy.replace_in_file( 78 | build_arduino_sketch_and_deploy.ARDUINO_SKETCH_FILEPATH, 79 | [ 80 | ( 81 | "#define ON_SIGNAL ---REPLACE_ME---", 82 | f"#define ON_SIGNAL {build_arduino_sketch_and_deploy.ON_SIGNAL}", 83 | ), 84 | ( 85 | "#define OFF_SIGNAL ---REPLACE_ME---", 86 | f"#define OFF_SIGNAL {build_arduino_sketch_and_deploy.OFF_SIGNAL}", 87 | ), 88 | ], 89 | destination_file.name, 90 | ) 91 | with open(destination_file.name, "r") as output: 92 | assert ( 93 | """#include 94 | 95 | // To deploy: 96 | // arduino-cli compile --fqbn arduino:renesas_uno:unor4wifi path/to/raspberry-pi-home-automation/ArduinoSketch/ 97 | // arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:renesas_uno:unor4wifi path/to/raspberry-pi-home-automation/ArduinoSketch/ 98 | // To monitor: arduino-cli monitor -p /dev/ttyACM0. This will interrupt the connection any other process might have 99 | // with the Arduino (door-sensor.py for instance). 100 | 101 | RCSwitch mySwitch = RCSwitch(); 102 | 103 | // The RF receiver must be connected from VCC to 5V, GND to GND, and any of the two remaining pins to pin #2 on the Arduino, the 4th pin being useless. 104 | // The PIR sensor must be connected from VCC to 5V, GND to GND, D1 to pin #3 on the Arduino, the 4th pin being useless. 105 | 106 | #define RF_RECEIVER 2 107 | #define PIR_MOTION_SENSOR 3 108 | #define BUZZER 4 109 | 110 | #define ON_SIGNAL 123123 111 | #define OFF_SIGNAL 321321 112 | 113 | void setup() { 114 | Serial.begin(9600); // To enable writing logs. As a side effect, this allows communication with the Raspberry Pi. 115 | mySwitch.enableReceive(RF_RECEIVER); 116 | pinMode(PIR_MOTION_SENSOR, INPUT); 117 | pinMode(BUZZER, OUTPUT); 118 | } 119 | 120 | void playOnSound() { 121 | digitalWrite(BUZZER, HIGH); // digitalWrite() for active buzzers, tone() for passive ones 122 | delay(100); 123 | digitalWrite(BUZZER, LOW); 124 | delay(100); 125 | digitalWrite(BUZZER, HIGH); 126 | delay(100); 127 | digitalWrite(BUZZER, LOW); 128 | } 129 | 130 | void playOffSound() { 131 | digitalWrite(BUZZER, HIGH); 132 | delay(300); 133 | digitalWrite(BUZZER, LOW); 134 | } 135 | 136 | // We do not want to systematically send detected motion to the Raspberry Pi, when the alarm is disengaged, because 137 | // otherwise we would send too many "Motion detected" events to the Raspberry Pi, and the Pi would take time to process 138 | // them all, delaying the processing of other events such as "ON pressed" or "OFF pressed". 139 | bool shouldReportDetectedMotion = true; 140 | 141 | void readInputFromRaspberryPi() { 142 | if (Serial.available() > 0) { 143 | String data = Serial.readStringUntil('\\n'); 144 | 145 | if (data == "play_on_sound") { 146 | playOnSound(); 147 | } 148 | if (data == "disarm_alarm") { 149 | playOffSound(); 150 | shouldReportDetectedMotion = false; 151 | } 152 | if (data == "arm_alarm") { 153 | shouldReportDetectedMotion = true; 154 | } 155 | } 156 | } 157 | 158 | void loop() { 159 | readInputFromRaspberryPi(); 160 | 161 | if (mySwitch.available()) { // If we received a RF signal 162 | int value = mySwitch.getReceivedValue(); 163 | 164 | if (value == ON_SIGNAL) { 165 | Serial.println("ON pressed"); 166 | } 167 | else if (value == OFF_SIGNAL) { 168 | Serial.println("OFF pressed"); 169 | } 170 | else { 171 | Serial.println("Unknown message received: " + String(value)); 172 | } 173 | 174 | mySwitch.resetAvailable(); 175 | } 176 | else { 177 | // Serial.println("No message received"); 178 | } 179 | 180 | if (digitalRead(PIR_MOTION_SENSOR) && shouldReportDetectedMotion) { 181 | Serial.println("Motion detected"); 182 | } 183 | else { 184 | // Serial.println("No motion"); 185 | } 186 | 187 | delay(50); 188 | } 189 | """ 190 | == output.read() 191 | ) 192 | 193 | @patch("builtins.open", MockOpenForConfigOnly(read_data=mock_config_file).open) 194 | @patch("os.system") 195 | def test_that_it_works(self, mock_os_system): 196 | reload(build_arduino_sketch_and_deploy) 197 | with patch.object( 198 | build_arduino_sketch_and_deploy, "replace_in_file" 199 | ) as mocked_replace_in_file: 200 | assert build_arduino_sketch_and_deploy.build_and_deploy() 201 | mock_os_system.assert_any_call("sudo systemctl stop door-sensor") 202 | assert mock_os_system.mock_calls[1].startsWith( 203 | "call('arduino-cli compile --fqbn arduino:renesas_uno:unor4wifi" 204 | ) 205 | mock_os_system.assert_any_call("sudo systemctl start door-sensor") 206 | 207 | assert mock_os_system.call_count == 3 208 | 209 | mocked_replace_in_file.assert_called_once_with( 210 | build_arduino_sketch_and_deploy.ARDUINO_SKETCH_FILEPATH, 211 | [ 212 | ( 213 | "#define ON_SIGNAL ---REPLACE_ME---", 214 | "#define ON_SIGNAL 123123", 215 | ), 216 | ( 217 | "#define OFF_SIGNAL ---REPLACE_ME---", 218 | "#define OFF_SIGNAL 321321", 219 | ), 220 | ], 221 | ANY, # The destination temp file 222 | ) 223 | -------------------------------------------------------------------------------- /door-sensor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Inspired from: https://simonprickett.dev/playing-with-raspberry-pi-door-sensor-fun/ 4 | 5 | import RPi.GPIO as GPIO 6 | import time 7 | import sys, os 8 | import signal 9 | import redis 10 | import requests 11 | import importlib 12 | import threading 13 | import logging 14 | from timeit import default_timer as timer 15 | import serial 16 | from home_automation.config import GOOGLE_SCRIPTS_URL 17 | 18 | DOOR_SENSOR_PIN = 18 19 | 20 | # Set Broadcom mode so we can address GPIO pins by number. 21 | GPIO.setmode(GPIO.BCM) 22 | 23 | format_logs = "%(asctime)s: %(message)s" 24 | logging.basicConfig(stream=sys.stdout, format=format_logs, level=logging.INFO) 25 | 26 | # The Arduino will occasionally stop responding completely, after a long uptime. Only a full reboot 27 | # of the Raspberry Pi will make it work again. 28 | arduino = serial.Serial( 29 | "/dev/ttyACM0", 9600, timeout=1 30 | ) # 9600 must be the same number as in the Arduino code 31 | 32 | # Flush any byte that could already be in the input buffer, 33 | # to avoid receiving weird/not useful/not complete data at the beginning of the communication. 34 | arduino.reset_input_buffer() 35 | 36 | # Same for the output buffer 37 | arduino.reset_output_buffer() 38 | 39 | 40 | # Clean up when the user exits with keyboard interrupt 41 | def cleanup(signal, frame): 42 | logging.info("Exiting...") 43 | GPIO.cleanup() 44 | sys.exit(0) 45 | 46 | 47 | def send_request(data): 48 | try: 49 | response = requests.get( 50 | GOOGLE_SCRIPTS_URL, 51 | params={ 52 | "datetime": data["timestamp"], 53 | "door_status": data["door_status"], 54 | }, 55 | ) 56 | return response.status_code == 200 57 | except requests.exceptions.RequestException: 58 | logging.error("RequestException!") 59 | return False 60 | except BaseException as error: 61 | logging.error("BaseException!") 62 | logging.error(error) 63 | return False 64 | 65 | 66 | def post_to_google_scripts(data, r, last_thread): 67 | if last_thread != None: 68 | if last_thread.is_alive(): 69 | logging.info("Waiting on previous call to Google Scripts to complete...") 70 | else: 71 | logging.info("Previous call to Google Scripts already complete") 72 | 73 | last_thread.join(30) 74 | if last_thread.is_alive(): 75 | logging.info( 76 | "Previous call's thread still alive. Ignoring and proceeding..." 77 | ) 78 | 79 | logging.info("Sending to Google Scripts...") 80 | 81 | successfully_sent = False 82 | try: 83 | successfully_sent = send_request(data) 84 | except BaseException as e: 85 | logging.error("Error: %s" % str(e)) 86 | 87 | if successfully_sent: 88 | logging.info(f"Successfully posted to Google Scripts {data}") 89 | 90 | if not successfully_sent: 91 | logging.warning("Failed to post to Google Scripts") 92 | logging.warning(data) 93 | os.system( 94 | f'echo "{str(data)}" | mail -s "Failed to post to Google." root@localhost' 95 | ) 96 | 97 | 98 | # Set up the door sensor pin. 99 | GPIO.setup(DOOR_SENSOR_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) 100 | 101 | # Set the cleanup handler for when user hits Ctrl-C to exit 102 | signal.signal(signal.SIGINT, cleanup) 103 | 104 | logging.info("Listening to the door state change...") 105 | 106 | # Initially we don't know if the door sensor is open or closed... 107 | isOpen = "unknown (script started)" 108 | oldIsOpen = None 109 | last_thread = None 110 | 111 | r = redis.Redis("localhost", 6379, charset="utf-8", decode_responses=True) 112 | # TODO: put "alarm_state" in a constant, and replace all occurrences 113 | current_or_future_alarm_state = r.get("alarm_state") or "unknown" 114 | actual_current_alarm_state = current_or_future_alarm_state 115 | set_alarm_at_time = None 116 | 117 | ALARM_ARMED = "1" 118 | ALARM_DISARMED = "0" 119 | 120 | REEMIT_AFTER_SECONDS = 15.0 121 | start_time = None 122 | last_motion_detected_at = None 123 | logging.info(f"Current alarm state: {actual_current_alarm_state}") 124 | 125 | while True: 126 | oldIsOpen = isOpen 127 | isOpen = GPIO.input(DOOR_SENSOR_PIN) 128 | 129 | if isOpen != oldIsOpen: 130 | start_time = timer() 131 | 132 | door_status = "open" if isOpen else "closed" 133 | message = f"{door_status} (was {oldIsOpen})" 134 | logging.info("Door is currently " + message) 135 | 136 | r.publish("door_status", door_status) 137 | logging.info("Status sent to Redis") 138 | 139 | now = time.strftime("%d/%m/%Y %H:%M:%S", time.localtime()) 140 | data = {"timestamp": now, "door_status": message} 141 | 142 | last_thread = threading.Thread( 143 | target=post_to_google_scripts, args=[data, r, last_thread] 144 | ) 145 | last_thread.start() 146 | 147 | if (isOpen) and (isOpen == oldIsOpen): 148 | if (timer() - start_time) >= REEMIT_AFTER_SECONDS: 149 | start_time = timer() 150 | r.publish("door_status", "still_open") 151 | logging.info("Re-emitted status (still_open) to Redis") 152 | 153 | if arduino.in_waiting > 0: 154 | message = arduino.readline().decode("utf-8").rstrip() 155 | 156 | # TODO: put this string in a constant 157 | if message == "Motion detected": 158 | if current_or_future_alarm_state == actual_current_alarm_state: 159 | # Let's refetch the latest state from Redis, cause the alarm might have been 160 | # armed or disarmed remotely through the Google Sheet. 161 | # TODO: test this behavior - and factorize with the same lines further down below 162 | current_or_future_alarm_state = r.get("alarm_state") 163 | actual_current_alarm_state = current_or_future_alarm_state 164 | 165 | if actual_current_alarm_state == ALARM_ARMED: 166 | # We detected motion, and alarm is armed 167 | # If the alarm is not armed, we ignore motion detection, cause that would happen all the time 168 | 169 | now_in_seconds = int(time.time()) 170 | 171 | if (last_motion_detected_at == None) or ( 172 | (last_motion_detected_at + int(REEMIT_AFTER_SECONDS)) 173 | < now_in_seconds 174 | ): 175 | # We detected motion for the first time, or it's been more than 15 secondes since the last initial detection 176 | last_motion_detected_at = now_in_seconds 177 | logging.info(f"Received from Arduino: {message}") 178 | 179 | # Let's raise the alarm only if the door is not already open 180 | if not isOpen: 181 | now = time.strftime("%d/%m/%Y %H:%M:%S", time.localtime()) 182 | data = {"timestamp": now, "door_status": "motion detected"} 183 | last_thread = threading.Thread( 184 | target=post_to_google_scripts, args=[data, r, last_thread] 185 | ) 186 | last_thread.start() 187 | r.publish("door_status", "motion") 188 | 189 | # TODO: put these 2 strings in constants 190 | if message == "ON pressed" or message == "OFF pressed": 191 | logging.info(f"Received from Arduino: {message}") 192 | new_alarm_state = ( 193 | ALARM_ARMED if (message == "ON pressed") else ALARM_DISARMED 194 | ) 195 | 196 | if current_or_future_alarm_state == actual_current_alarm_state: 197 | # Let's refetch the latest state from Redis, cause the alarm might have been 198 | # armed or disarmed remotely through the Google Sheet. 199 | # As an example, if it was armed and was then disarmed through the sheet, 200 | # no need to play any sound on the Arduino. 201 | # TODO: test this behavior 202 | current_or_future_alarm_state = r.get("alarm_state") 203 | actual_current_alarm_state = current_or_future_alarm_state 204 | 205 | if new_alarm_state != current_or_future_alarm_state: 206 | if message == "ON pressed": 207 | arduino.write("play_on_sound\n".encode("utf-8")) 208 | logs_message = "ON" 209 | set_alarm_at_time = int(time.time()) + 30 210 | else: 211 | arduino.write("disarm_alarm\n".encode("utf-8")) 212 | logs_message = "OFF" 213 | set_alarm_at_time = None 214 | logging.info(f"REDIS: Set alarm_state to {ALARM_DISARMED}") 215 | actual_current_alarm_state = ALARM_DISARMED 216 | r.set("alarm_state", ALARM_DISARMED) 217 | 218 | logs_message = ( 219 | f"ALARM {logs_message} (was {current_or_future_alarm_state})" 220 | ) 221 | 222 | logging.info(logs_message) 223 | current_or_future_alarm_state = new_alarm_state 224 | 225 | now = time.strftime("%d/%m/%Y %H:%M:%S", time.localtime()) 226 | data = {"timestamp": now, "door_status": logs_message} 227 | last_thread = threading.Thread( 228 | target=post_to_google_scripts, args=[data, r, last_thread] 229 | ) 230 | last_thread.start() 231 | 232 | if (set_alarm_at_time != None) and (int(time.time()) > set_alarm_at_time): 233 | logging.info(f"REDIS: Set alarm_state to {ALARM_ARMED}") 234 | set_alarm_at_time = None 235 | actual_current_alarm_state = ALARM_ARMED 236 | r.set("alarm_state", ALARM_ARMED) 237 | arduino.write("arm_alarm\n".encode("utf-8")) 238 | 239 | time.sleep(0.1) 240 | --------------------------------------------------------------------------------