Compare commits
	
		
			8 Commits
		
	
	
		
			8ca102de26
			...
			21c9b05182
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 21c9b05182 | |||
|   | ebeacd72ae | ||
|   | 6ba5cc6815 | ||
|   | 91df8a0f26 | ||
|   | b1ba481089 | ||
|   | 1c8980f3a5 | ||
|   | eb4a70a7b1 | ||
|   | a156c46015 | 
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							|  | @ -158,7 +158,4 @@ cython_debug/ | |||
| #  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore | ||||
| #  and can be added to the global gitignore or merged into this file.  For a more nuclear | ||||
| #  option (not recommended) you can uncomment the following to ignore the entire idea folder. | ||||
| #.idea/ | ||||
| 
 | ||||
| bluewind | ||||
| bluewind/* | ||||
| #.idea/ | ||||
							
								
								
									
										23
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								README.md
									
									
									
									
									
								
							|  | @ -6,10 +6,29 @@ Bluetooth control app for Wahoo Headwind. | |||
| 
 | ||||
| ## Usage | ||||
| 
 | ||||
| To run the flask app: | ||||
| To run the flask app for development and testing: | ||||
| ```bash | ||||
| git clone https://teapot.octopusx.de/accidentallycompetent/bluewind.git | ||||
| cd bluewind | ||||
| python3 -m venv .venv | ||||
| source .venv/bin/activate | ||||
| python3 -m pip install --upgrade pip | ||||
| pip3 install -r requirements.txt | ||||
| export FLASK_ADDRESS='<headwind bluetooth address>' | ||||
| flask --app api run --host=0.0.0.0 | ||||
| export FLASK_URL='<address and port to bind' | ||||
| python3 main.py | ||||
| ``` | ||||
| 
 | ||||
| To run the flask app in a permanent deployment: | ||||
| ```bash | ||||
| git clone https://teapot.octopusx.de/accidentallycompetent/bluewind.git | ||||
| cd bluewind | ||||
| python3 -m venv .venv | ||||
| source .venv/bin/activate | ||||
| python3 -m pip install --upgrade pip | ||||
| pip3 install -r requirements.txt | ||||
| cp default.env .env # modify your .env file! | ||||
| sudo /bin/bash scripts/install.sh # will install and start+enable the systemd service for you | ||||
| ``` | ||||
| 
 | ||||
| ## Support | ||||
|  |  | |||
							
								
								
									
										46
									
								
								api.py
									
									
									
									
									
								
							
							
						
						
									
										46
									
								
								api.py
									
									
									
									
									
								
							|  | @ -1,46 +0,0 @@ | |||
| #!/usr/bin/env python3 | ||||
| from flask import Flask | ||||
| from headwind import Headwind as Headwind | ||||
| 
 | ||||
| app = Flask(__name__) | ||||
| app.config.from_prefixed_env() | ||||
| 
 | ||||
| @app.route("/on") | ||||
| async def on(): | ||||
|     fan = Headwind(app, app.config["ADDRESS"]) | ||||
|     fan_status = await fan.on() | ||||
|     if not fan_status: | ||||
|         return "<p>Failed to turn headwind on</p>", 503 | ||||
|     return "<p>Turning headwind on</p>", 200 | ||||
| 
 | ||||
| @app.route("/sleep") | ||||
| async def sleep(): | ||||
|     fan = Headwind(app, app.config["ADDRESS"]) | ||||
|     fan_status = await fan.sleep() | ||||
|     if not fan_status: | ||||
|         return "<p>Failed to put headwind to sleep</p>", 503 | ||||
|     return "<p>Putting headwind to sleep</p>", 200 | ||||
| 
 | ||||
| @app.route("/speed/<int:speed>") | ||||
| async def speed(speed): | ||||
|     fan = Headwind(app, app.config["ADDRESS"]) | ||||
|     fan_status = await fan.manualSpeed(speed) | ||||
|     if not fan_status: | ||||
|         return f"<p>Failed to set headwind speed to {speed}</p>", 503 | ||||
|     return f"<p>Setting headwind speed to {speed}</p>", 200 | ||||
| 
 | ||||
| @app.route("/hr") | ||||
| async def hr(): | ||||
|     fan = Headwind(app, app.config["ADDRESS"]) | ||||
|     fan_status = await fan.hr() | ||||
|     if not fan_status: | ||||
|         return "<p>Failed to set headwind to HR mode</p>", 503 | ||||
|     return "<p>Setting headwind to HR mode</p>", 200 | ||||
| 
 | ||||
| @app.route("/off") | ||||
| async def off(): | ||||
|     fan = Headwind(app, app.config["ADDRESS"]) | ||||
|     fan_status = await fan.off() | ||||
|     if not fan_status: | ||||
|         return "<p>Failed to turn headwind off</p>", 503 | ||||
|     return "<p>Turning headwind off</p>", 200 | ||||
							
								
								
									
										12
									
								
								bluewind/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								bluewind/__init__.py
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,12 @@ | |||
| # Initialise flask | ||||
| from flask import Flask | ||||
| app = Flask(__name__) | ||||
| app.config['SERVER_NAME'] = '0.0.0.0:5000' | ||||
| app.config.from_prefixed_env() | ||||
| 
 | ||||
| # Initialise the bluetooth stack | ||||
| from bluewind import headwind | ||||
| fan = headwind.Headwind(app, app.config["ADDRESS"]) | ||||
| 
 | ||||
| # Load the views | ||||
| from bluewind import views | ||||
|  | @ -11,6 +11,8 @@ MIN_SPEED = [0x2, 0x1] | |||
| HALF_SPEED = [0x2, 0x32] | ||||
| FULL_SPEED = [0x2, 0x64] | ||||
| CHARACTERISTIC = "a026e038-0a7d-4ab3-97fa-f1500f9feb8b" | ||||
| # > read the above characteristic 0xFD-01-XX-04 where XX is speed  | ||||
| # > bytearray(b'\xfd\x01\x00\x01') | ||||
| 
 | ||||
| class Headwind: | ||||
|     fanClient = None | ||||
|  | @ -19,7 +21,23 @@ class Headwind: | |||
|         self.flaskApp = flaskApp | ||||
|         self.fanClient = BleakClient(address) | ||||
| 
 | ||||
|     async def sleep(self): | ||||
|     async def readSpeed(self): | ||||
|         try: | ||||
|             async with self.fanClient as client: | ||||
|                 result = await client.read_gatt_char(CHARACTERISTIC) | ||||
|                 return result[2] | ||||
|         except Exception: | ||||
|             return 0 | ||||
| 
 | ||||
|     async def readMode(self): | ||||
|         try: | ||||
|             async with self.fanClient as client: | ||||
|                 result = await client.read_gatt_char(CHARACTERISTIC) | ||||
|                 return result[3] # Return state code, needs to figure out what each code means | ||||
|         except Exception: | ||||
|             return 0 | ||||
| 
 | ||||
|     async def writeSleep(self): | ||||
|         try: | ||||
|             async with self.fanClient as client: | ||||
|                 await client.write_gatt_char(CHARACTERISTIC, SLEEP) | ||||
|  | @ -27,7 +45,7 @@ class Headwind: | |||
|         except Exception as e: | ||||
|             return False | ||||
|      | ||||
|     async def on(self): | ||||
|     async def writeOn(self): | ||||
|         try: | ||||
|             async with self.fanClient as client: | ||||
|                 await client.write_gatt_char(CHARACTERISTIC, ON) | ||||
|  | @ -35,7 +53,7 @@ class Headwind: | |||
|         except Exception as e: | ||||
|             return False | ||||
| 
 | ||||
|     async def speedMode(self): | ||||
|     async def writeSpeedMode(self): | ||||
|         try: | ||||
|             async with self.fanClient as client: | ||||
|                 await client.write_gatt_char(CHARACTERISTIC, SPD) | ||||
|  | @ -43,7 +61,7 @@ class Headwind: | |||
|         except Exception as e: | ||||
|             return False | ||||
| 
 | ||||
|     async def hrMode(self): | ||||
|     async def writeHrMode(self): | ||||
|         try: | ||||
|             async with self.fanClient as client: | ||||
|                 await client.write_gatt_char(CHARACTERISTIC, HR) | ||||
|  | @ -51,7 +69,7 @@ class Headwind: | |||
|         except Exception as e: | ||||
|             return False | ||||
| 
 | ||||
|     async def manualSpeed(self, speed): | ||||
|     async def writeSpeed(self, speed): | ||||
|         if speed > 0: | ||||
|             value = [0x2, speed] | ||||
|             try: | ||||
|  | @ -60,10 +78,9 @@ class Headwind: | |||
|                     return True | ||||
|             except Exception as e: | ||||
|                 return False | ||||
|         else: | ||||
|             return False | ||||
|         return False | ||||
| 
 | ||||
|     async def off(self): | ||||
|     async def writeOff(self): | ||||
|         try: | ||||
|             async with self.fanClient as client: | ||||
|                 await client.write_gatt_char(CHARACTERISTIC, OFF) | ||||
|  | @ -15,4 +15,16 @@ service001e = "a026ee0c-0a7d-4ab3-97fa-f1500f9feb8b"                    # Vendor | |||
| service001e_char001f = "a026e038-0a7d-4ab3-97fa-f1500f9feb8b"           # Vendor specific | ||||
| service001e_char001f_desc0021 = "00002902-0000-1000-8000-00805f9b34fb"  # Client Characteristic Configuration | ||||
| handle = 0x0000 | ||||
| address = 'F2:B3:F7:6A:24:48' | ||||
| address = 'F2:B3:F7:6A:24:48' | ||||
| 
 | ||||
| # bytearray(b'\xfd\x01\x19\x04') | ||||
| # 127.0.0.1 - - [10/Nov/2023 15:56:50] "GET /get/speed HTTP/1.1" 200 - | ||||
| # 127.0.0.1 - - [10/Nov/2023 15:57:14] "GET /speed/32 HTTP/1.1" 200 - | ||||
| # bytearray(b'\xfd\x01 \x04') | ||||
| # 127.0.0.1 - - [10/Nov/2023 15:57:23] "GET /get/speed HTTP/1.1" 200 - | ||||
| # 127.0.0.1 - - [10/Nov/2023 15:58:01] "GET /speed/50 HTTP/1.1" 200 - | ||||
| # bytearray(b'\xfd\x012\x04') | ||||
| # 127.0.0.1 - - [10/Nov/2023 15:58:14] "GET /get/speed HTTP/1.1" 200 - | ||||
| # 127.0.0.1 - - [10/Nov/2023 15:58:37] "GET /speed/100 HTTP/1.1" 200 - | ||||
| # bytearray(b'\xfd\x01d\x04') | ||||
| # 127.0.0.1 - - [10/Nov/2023 15:58:45] "GET /get/speed HTTP/1.1" 200 - | ||||
							
								
								
									
										80
									
								
								bluewind/views.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										80
									
								
								bluewind/views.py
									
									
									
									
									
										Executable file
									
								
							|  | @ -0,0 +1,80 @@ | |||
| #!/usr/bin/env python3 | ||||
| from bluewind import app, fan | ||||
| 
 | ||||
| @app.route("/on", methods=["POST"]) | ||||
| async def setOn(): | ||||
|     fan_status = await fan.writeOn() | ||||
|     if not fan_status: | ||||
|         return "Failed to turn headwind on", 503 | ||||
|     return "Turning headwind on", 200 | ||||
| 
 | ||||
| @app.route("/sleep", methods=["POST"]) | ||||
| async def setSleep(): | ||||
|     fan_status = await fan.writeSleep() | ||||
|     if not fan_status: | ||||
|         return "Failed to put headwind to sleep", 503 | ||||
|     return "Putting headwind to sleep", 200 | ||||
| 
 | ||||
| @app.route("/speed/<int:speed>", methods=["POST"]) | ||||
| async def setSpeed(speed): | ||||
|     fan_status = await fan.writeSpeed(speed) | ||||
|     if not fan_status: | ||||
|         return f"Failed to set headwind speed to {speed}", 503 | ||||
|     return f"Setting headwind speed to {speed}", 200 | ||||
| 
 | ||||
| @app.route("/speed/increase", methods=["POST"]) | ||||
| async def setIncreaseSpeed(): | ||||
|     print("increase speed") | ||||
|     currentSpeed = await fan.readSpeed() | ||||
|     newSpeed = 0 | ||||
|     if currentSpeed < 25: | ||||
|         newSpeed = 25 | ||||
|     elif currentSpeed < 50: | ||||
|         newSpeed = 50 | ||||
|     elif currentSpeed < 75: | ||||
|         newSpeed = 75 | ||||
|     elif currentSpeed < 100: | ||||
|         newSpeed = 100 | ||||
|     fan_status = await fan.writeSpeed(newSpeed) | ||||
|     if not fan_status: | ||||
|         return f"Failed to increase speed by 25%", 503 | ||||
|     return f"Increased speed by 25%", 200 | ||||
| 
 | ||||
| @app.route("/speed/decrease", methods=["POST"]) | ||||
| async def setDecreaseSpeed(): | ||||
|     print("decrease speed") | ||||
|     currentSpeed = await fan.readSpeed() | ||||
|     newSpeed = 0 | ||||
|     if currentSpeed == 100: | ||||
|         newSpeed = 75 | ||||
|     elif currentSpeed >= 75: | ||||
|         newSpeed = 50 | ||||
|     elif currentSpeed >= 50: | ||||
|         newSpeed = 25 | ||||
|     elif currentSpeed > 25: | ||||
|         newSpeed = 25 | ||||
|     elif currentSpeed <= 25: | ||||
|         newSpeed = 1 | ||||
|     fan_status = await fan.writeSpeed(newSpeed) | ||||
|     if not fan_status: | ||||
|         return f"Failed to decrease speed by 25%", 503 | ||||
|     return f"Decreased speed by 25%", 200 | ||||
| 
 | ||||
| @app.route("/speed", methods=["GET"]) | ||||
| async def getSpeed(): | ||||
|     speed = await fan.readSpeed() | ||||
|     return f"{speed}", 200 | ||||
| 
 | ||||
| @app.route("/hr", methods=["POST"]) | ||||
| async def setHr(): | ||||
|     fan_status = await fan.writeHr() | ||||
|     if not fan_status: | ||||
|         return "Failed to set headwind to HR mode", 503 | ||||
|     return "Setting headwind to HR mode", 200 | ||||
| 
 | ||||
| @app.route("/off", methods=["POST"]) | ||||
| async def setOff(): | ||||
|     fan_status = await fan.off() | ||||
|     if not fan_status: | ||||
|         return "Failed to turn headwind off", 503 | ||||
|     return "Turning headwind off", 200 | ||||
|  | @ -1,12 +0,0 @@ | |||
| class Configurinator: | ||||
|     address = "" | ||||
|     cmd = "" | ||||
|     speed = "" | ||||
|   | ||||
|     def __init__(self): | ||||
|         pass | ||||
| 
 | ||||
|     def load_config(self, address, cmd, speed): | ||||
|         self.address = address | ||||
|         self.cmd = cmd | ||||
|         self.speed = speed | ||||
|  | @ -1 +1,2 @@ | |||
| FLASK_ADDRESS='F2:B3:F7:6A:24:48' | ||||
| FLASK_ADDRESS='F2:B3:F7:6A:24:48' | ||||
| FLASK_URL='127.0.0.1:5000' | ||||
							
								
								
									
										33
									
								
								main.py
									
									
									
									
									
								
							
							
						
						
									
										33
									
								
								main.py
									
									
									
									
									
								
							|  | @ -1,32 +1,5 @@ | |||
| #!/usr/bin/env python3 | ||||
| import asyncio | ||||
| import click | ||||
| from config import Configurinator as Config | ||||
| from headwind import Headwind as Headwind | ||||
| from bluewind import app | ||||
| 
 | ||||
| @click.command() | ||||
| @click.option('--address', default=None, help='headwind mac address') | ||||
| @click.option('--cmd', default=None, help='command to send') | ||||
| @click.option('--speed', default=1, help='manual speed value, 1 to 100') | ||||
| def main(address, cmd, speed): | ||||
|     conf = Config() | ||||
|     conf.load_config(address, cmd, speed) | ||||
|     asyncio.run(bluewind(conf)) | ||||
| 
 | ||||
| async def bluewind(conf): | ||||
|     fan = Headwind(conf.address) | ||||
|     match conf.cmd: | ||||
|         case 'on': | ||||
|             print("turning fan on") | ||||
|             await fan.on() | ||||
|         case 'sleep': | ||||
|             print("putting fan to sleep") | ||||
|             await fan.sleep() | ||||
|         case 'manual': | ||||
|             print("setting fan speed") | ||||
|             await fan.speed(conf.speed) | ||||
|     print('stuff') | ||||
| 
 | ||||
| 
 | ||||
| if __name__ == "__main__": | ||||
|     main() | ||||
| if __name__ == '__main__': | ||||
|     app.run(debug=False) | ||||
							
								
								
									
										31
									
								
								scripts/install.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								scripts/install.sh
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,31 @@ | |||
| #!/bin/bash | ||||
| # Export the environment variables | ||||
| if [ ! -f ".env" ]; then | ||||
|   echo "The .env file does not exist, please make a copy of default.env into .env and adjust its contents before proceeding." | ||||
|   exit 1 | ||||
| fi | ||||
| export $(cat .env) | ||||
| 
 | ||||
| # Create new systemd file | ||||
| touch /etc/systemd/system/bluewind.service | ||||
| 
 | ||||
| # Fill in the systemd file | ||||
| echo -e """[Unit] | ||||
| Description=Flask Application | ||||
| After=multi-user.target | ||||
| 
 | ||||
| [Service] | ||||
| Type=simple | ||||
| ExecStart=$(pwd)/.venv/bin/python3 $(pwd)/main.py | ||||
| Restart=always | ||||
| Environment="FLASK_ADDRESS=$(echo $FLASK_ADDRESS)" | ||||
| Environment="FLASK_URL=$(echo $FLASK_URL)" | ||||
| 
 | ||||
| [Install] | ||||
| WantedBy=multi-user.target | ||||
| """ > /etc/systemd/system/bluewind.service | ||||
| 
 | ||||
| # Reload systemd unit, start and enable the service | ||||
| systemctl daemon-reload | ||||
| systemctl enable bluewind.service | ||||
| systemctl start bluewind.service | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user