├── 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 | 
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 |
--------------------------------------------------------------------------------