Skip to content

Commit

Permalink
Set up button to toggle speaker state (#2)
Browse files Browse the repository at this point in the history
* attempt to set up button

* add type ignores to suppress warnings

* ignore another type

* add missing pin in controller and type ignore

* ensure env file can be read and fix speaker turn on bug

* improve logs for debugging, and use asyncio create_task instead of another run

* style

* logging with plug name

* revert to asyncio.run

* add debugging note

* use singletons for controllers

* add to requirements-rpi

* better errors and asyncio loop management

* fix loop management for repeated button presses

* fix singleton bug, there should be one instance per ip-name plug

* update readme

* style readme

* fix tests after refactoring

* style

* add citation
  • Loading branch information
plisker authored Aug 17, 2024
1 parent 17341ee commit 74fe552
Show file tree
Hide file tree
Showing 14 changed files with 286 additions and 37 deletions.
89 changes: 79 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,102 +23,170 @@ cd speakersaver
```

### 2. Set Up the Python Environment

Create a virtual environment and install the required dependencies:

```bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```

### 3. Run the Setup Script

Run the setup script to configure the environment variables. This script will prompt you to input the necessary details, such as your Spotify `CLIENT_ID`, `CLIENT_SECRET`, and the IP addresses of your speakers and TV:

```bash
python setup_env.py
```

This script will generate a `.env` file with your configurations.

### 4. Authorize Spotify Access

The first time you run the `auth.py` script, you'll need to authorize your Spotify app. Start the Flask server:

```bash
python -m src.auth
```
Navigate to the `/authorize` endpoint in your browser to complete the Spotify authorization process. For example, if you're running the Flask server locally, visit `http://localhost:8888/authorize`.

Navigate to the `/authorize` endpoint in your browser to complete the Spotify authorization process. For example, if you're running the Flask server locally, visit `http://localhost:8888/authorize`.

## Running the Project

### 1. Running Locally

To run the main monitoring script:

```bash
python -m src.main
```

To start the Flask server for handling Spotify authorization and exposing health endpoints:

```bash
python -m src.auth
```

### 2. Deploying to Raspberry Pi

#### Step 1: Transfer the Project to Raspberry Pi

Use `scp` to copy the entire project directory to your Raspberry Pi:

```bash
scp -r /path/to/speakersaver pi@<raspberry_pi_ip>:/home/pi/speakersaver
```

#### Step 2: Set Up the Environment on Raspberry Pi

SSH into your Raspberry Pi and navigate to the project directory:

```bash
ssh pi@<raspberry_pi_ip>
cd /home/pi/speakersaver
```

Create and activate a virtual environment, then install dependencies:

```bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```

Run the setup script:

```bash
python3 setup_env.py
```

#### Step 3: Configure the Service to Run on Startup
Create a `systemd` service to start your project automatically on boot:

##### Step 1: Create a `start_services.sh` script to run your project

1. Create script file:

```bash
sudo nano ~/start_services.sh
```

2. Add the following script:

```bash
#!/bin/bash

echo "Starting speaker saver service"
sleep 10

echo "Starting virtual environment"
cd /home/plisker/speaker-saver
source venv/bin/activate
echo "Virtual environment is ready"

env > /home/plisker/speaker-saver/script_env.txt

# Run auth and main scripts
python -m src.auth &
AUTH_PID=$!
echo "Auth script started with PID: $AUTH_PID"

python -m src.main &
MAIN_PID=$!
echo "Main script started with PID: $MAIN_PID"

wait $AUTH_PID
wait $MAIN_PID
```

##### Step 2: Create a `systemd` service to start your project automatically on boot

1. Create a service file:

```bash
sudo nano /etc/systemd/system/speaker-saver.service
```
2. Add the following configuration:

2. Add the following configuration, making sure to change your paths as necessary:

```ini
[Unit]
Description=Speaker Saver Service
After=network-online.target
After=network.target

[Service]
ExecStart=/home/pi/speaker-saver/start_services.sh
WorkingDirectory=/home/pi/speaker-saver
Type=simple
ExecStart=/home/plisker/start_services.sh
WorkingDirectory=/home/plisker/speaker-saver
StandardOutput=inherit
StandardError=inherit
Restart=always
User=pi
Environment="PATH=/home/pi/speaker-saver/venv/bin:/usr/bin"
User=plisker

[Install]
WantedBy=multi-user.target
```

3. Enable and start the service:

```bash
sudo systemctl enable speaker-saver.service
sudo systemctl start speaker-saver.service
```

## Debugging

If, on the Raspberry Pi, you get an error about the `add_event_handler`, you [may need](https://forums.raspberrypi.com/viewtopic.php?p=2230294&sid=ed8d0635e8e6760b1919468cb751a5e1#p2230294) to run this after activating the venv:

```bash
sudo apt remove python3-rpi.gpio
```

## Health Monitoring

SpeakerSaver logs its health status to `health.log` and makes this information available through an HTTP endpoint exposed by the Flask server. You can monitor the system’s health by visiting the `/health` endpoint provided by the Flask server and a simple log in the `/logs` endpoint.

## Port Forwarding

If you need to access the endpoints on your Raspberry Pi from your local computer, you can set up port forwarding. Add the following configuration to your `~/.ssh/config` file:

```ini
Expand All @@ -133,4 +201,5 @@ After running `ssh raspberrypi`, you will be able to access the endpoints on you
Please note that the `HostName` may differ depending on your Raspberry Pi's configuration.

## Future Enhancements
PIP Distribution: In the future, the project will be packaged for easy installation via PIP, eliminating the need for manual SCP transfers.

PIP Distribution: In the future, the project will be packaged for easy installation via PIP, eliminating the need for manual SCP transfers.
3 changes: 3 additions & 0 deletions requirements-rpi.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
wheel
RPi.GPIO
rpi-lgpio
4 changes: 3 additions & 1 deletion src/auth.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from flask import Flask, redirect, request

from src.instances import get_spotify_controller
from src.utils.logging import HEALTH_LOG_FILE
from src.instances import spotify_controller

app = Flask(__name__)

spotify_controller = get_spotify_controller()


@app.route("/authorize")
def authorize():
Expand Down
57 changes: 57 additions & 0 deletions src/controllers/button_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import asyncio
import logging
import RPi.GPIO as GPIO # type: ignore

from src.controllers.singleton_base import SingletonMeta
from src.controllers.smart_plug_controller import SmartPlugController


class ButtonController(metaclass=SingletonMeta):
def __init__(
self,
speakers_controller: SmartPlugController,
mixer_controller: SmartPlugController,
pin: int = 2,
):
self.speakers_controller = speakers_controller
self.mixer_controller = mixer_controller
self.pin = pin
self.setup_gpio()

def setup_gpio(self):
GPIO.setmode(GPIO.BCM)
GPIO.setup(self.pin, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
GPIO.add_event_detect(
self.pin, GPIO.RISING, callback=self.button_callback, bouncetime=5000
)

def button_callback(self, channel):
"""Callback function that runs when the button is pressed"""
logging.info("Button pressed.")

# Create a new event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)

# Run the coroutine in the event loop
loop.run_until_complete(self.toggle_speakers())

def read_button_state(self):
logging.info(f"The button value is {GPIO.input(self.pin)}")
print(f"For Shree, the button value is {GPIO.input(self.pin)}")

async def toggle_speakers(self):
"""Toggles the state of the speakers.
Speakers must turn off before mixer, but mixer must turn on before speakers."""
try:
is_on = await self.speakers_controller.is_on()

if is_on:
await self.speakers_controller.turn_off()
logging.info("Speakers turned off manually.")
else:
await self.mixer_controller.turn_on()
logging.info("Speakers turned on manually.")
except Exception as e:
logging.error("Unable to check state of speakers, so ignoring button press")
7 changes: 7 additions & 0 deletions src/controllers/singleton_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class SingletonMeta(type):
_instances = {}

def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
41 changes: 33 additions & 8 deletions src/controllers/smart_plug_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,34 @@


class SmartPlugController:
def __init__(self, ip_address):
_instances = {}

def __new__(cls, ip_address, name):
key = (name, ip_address)
if key not in cls._instances:
cls._instances[key] = super().__new__(cls)
cls._instances[key].__init__(ip_address, name)
return cls._instances[key]

def __init__(self, ip_address, name: str):
if hasattr(self, "initialized"):
return
self.ip_address = ip_address
self.plug = SmartPlug(ip_address)
self.name = name
self.initialized = True

async def turn_off(self):
try:
await self.plug.update()
logging.debug(f"Plug status: {self.plug.is_on}")
logging.debug(f"Plug {self.name} status: {self.plug.is_on}")
if self.plug.is_on:
await self.plug.turn_off()
logging.info("Speakers turned off.")
logging.info(f"Plug {self.name} turned off.")
else:
logging.info("Speakers are already off.")
logging.info(f"Plug {self.name} are already off.")
except Exception as e:
logging.error(f"An error occurred: {e}")
logging.error(f"An error occurred while turning off {self.name}: {e}")
logging.error("Ensure the Kasa plug is online and accessible.")
logging.error(f"Attempted to connect to IP: {self.ip_address}")

Expand All @@ -26,11 +39,23 @@ async def turn_on(self):
await self.plug.update()
logging.debug(f"Plug status: {self.plug.is_on}")
if self.plug.is_on:
logging.info("Speakers are already on.")
logging.info(f"Plug {self.name} is already on.")
else:
await self.plug.turn_on()
logging.info("Speakers turned on.")
logging.info(f"Plug {self.name} turned on.")
except Exception as e:
logging.error(f"An error occurred while turning on {self.name}: {e}")
logging.error("Ensure the Kasa plug is online and accessible.")
logging.error(f"Attempted to connect to IP: {self.ip_address}")

async def is_on(self) -> bool:
logging.info(f"Checking state of the plug {self.name}")
try:
await self.plug.update()
logging.info(f"Speaker state came back as {self.plug.is_on}")
return self.plug.is_on
except Exception as e:
logging.error(f"An error occurred: {e}")
logging.error(f"An error occurred while checking state: {e}")
logging.error("Ensure the Kasa plug is online and accessible.")
logging.error(f"Attempted to connect to IP: {self.ip_address}")
raise e
4 changes: 3 additions & 1 deletion src/controllers/spotify_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
from flask import Response, redirect
import requests

from src.controllers.singleton_base import SingletonMeta

class SpotifyController:

class SpotifyController(metaclass=SingletonMeta):
def __init__(
self, client_id, client_secret, redirect_uri, token_file="spotify_token.txt"
):
Expand Down
11 changes: 9 additions & 2 deletions src/controllers/tv_controller.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import requests

from src.controllers.singleton_base import SingletonMeta

class TVController:

class TVController(metaclass=SingletonMeta):
def __init__(self, ip_address):
self.ip_address = ip_address
self.url = f"http://{self.ip_address}/sony/system"
self.headers = {"Content-Type": "application/json"}

async def check_power_status(self) -> bool:
"""Determines whether the Sony TV is turned on"""
payload = {"method": "getPowerStatus", "params": [{}], "id": 1, "version": "1.0"}
payload = {
"method": "getPowerStatus",
"params": [{}],
"id": 1,
"version": "1.0",
}
try:
response = requests.post(self.url, json=payload, headers=self.headers)
if response.status_code == 200:
Expand Down
Loading

0 comments on commit 74fe552

Please sign in to comment.