Compare commits
50 commits
Author | SHA1 | Date | |
---|---|---|---|
297605b81e | |||
5e673e0c0e | |||
3f083c0aed | |||
d1bb240080 | |||
b0a5903c6e | |||
6066af50ac | |||
3cb59cc688 | |||
d3b2cc4c89 | |||
cb50f230b6 | |||
e2fa06d94e | |||
d2825a32ae | |||
8d625264d1 | |||
df102d8e4e | |||
aff797bcb5 | |||
9715339c43 | |||
e44a78742e | |||
7f4eae67f6 | |||
ea73b3702c | |||
31072baf97 | |||
de66a88fda | |||
2bcc99a893 | |||
d10359f8d1 | |||
6e42689a91 | |||
49c53b0f93 | |||
cef3904bd0 | |||
e088e7db32 | |||
eab3a825f2 | |||
075e7d277a | |||
30571d2439 | |||
91648b739f | |||
da780894d0 | |||
0ab2d6996e | |||
fc904f42bf | |||
44890aced6 | |||
1094fce783 | |||
1854c3ee27 | |||
931e7c1dc0 | |||
1f1020c53a | |||
051f0e8ed0 | |||
c25d099f0c | |||
6a85e24096 | |||
d9cd7a8688 | |||
393b14374b | |||
f90bfe5090 | |||
fa16909572 | |||
123ee48529 | |||
fb98e38e00 | |||
e94f2368d1 | |||
f860108b52 | |||
feefd55e20 |
12 changed files with 282 additions and 137 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
__pycache__
|
||||
src/climate.csv
|
|
@ -5,3 +5,4 @@ uvicorn
|
|||
fastapi-cors
|
||||
Adafruit_DHT
|
||||
requests
|
||||
mh_z19
|
48
src/actions.py
Normal file
48
src/actions.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
import asyncio
|
||||
from datetime import datetime
|
||||
import os
|
||||
from config import climate_log_file, dht22_pin
|
||||
|
||||
from handler.dht22_climate import Dht22Climate
|
||||
from handler.matrix_display import MatrixDisplay
|
||||
from handler.mhz19_co2 import Mhz19Co2
|
||||
|
||||
|
||||
climate_sensor = Dht22Climate(dht22_pin)
|
||||
co2_sensor = Mhz19Co2()
|
||||
matrix_display = MatrixDisplay()
|
||||
|
||||
|
||||
async def log_climate(delay_sec: int = 60):
|
||||
# If file does not exist, create it and write header
|
||||
if not os.path.isfile(climate_log_file):
|
||||
with open(climate_log_file, "w") as f:
|
||||
f.write("timestamp,temperature,humidity,co2\n")
|
||||
|
||||
while True:
|
||||
measurements = climate_sensor.read()
|
||||
co2_density = co2_sensor.read()
|
||||
if measurements is not None:
|
||||
with open(climate_log_file, "a") as f:
|
||||
f.write(
|
||||
"{},{},{},{}\n".format(
|
||||
datetime.now().isoformat(),
|
||||
measurements["temperature"],
|
||||
measurements["humidity"],
|
||||
co2_density,
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(delay_sec)
|
||||
|
||||
|
||||
async def display_time():
|
||||
while True:
|
||||
matrix_display.show_current_time()
|
||||
|
||||
seconds_until_next_minute = 60 - datetime.now().second
|
||||
await asyncio.sleep(seconds_until_next_minute)
|
||||
|
||||
|
||||
async def display_pattern(*args, **kwargs):
|
||||
while True:
|
||||
await matrix_display.pattern(*args, **kwargs)
|
2
src/config.py
Normal file
2
src/config.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
dht22_pin = 17
|
||||
climate_log_file = "./climate.csv"
|
0
src/handler/__init__.py
Normal file
0
src/handler/__init__.py
Normal file
48
src/handler/action_queue.py
Normal file
48
src/handler/action_queue.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
import asyncio
|
||||
import logging
|
||||
|
||||
class ActionQueue:
|
||||
def __init__(self, idle_action = None, *args, **kwargs) -> None:
|
||||
self.queued_actions: asyncio.Queue = asyncio.Queue()
|
||||
self.idle_action: tuple = (idle_action, args, kwargs)
|
||||
self.queue_task = asyncio.create_task(self.run_queue())
|
||||
self.idle_action_task = None
|
||||
|
||||
def __del___(self):
|
||||
self.queue_task.cancel()
|
||||
|
||||
if self.idle_action_task is not None:
|
||||
self.idle_action_task.cancel()
|
||||
|
||||
async def run_queue(self):
|
||||
while True:
|
||||
try:
|
||||
if self.queued_actions.empty() and self.idle_action[0] is not None:
|
||||
self.idle_action_task = asyncio.create_task(self.run_action(self.idle_action))
|
||||
|
||||
action = await self.queued_actions.get()
|
||||
|
||||
if self.idle_action_task is not None:
|
||||
self.idle_action_task.cancel()
|
||||
self.idle_action_task = None
|
||||
|
||||
await self.run_action(action)
|
||||
|
||||
except Exception as ex:
|
||||
logging.exception("Something went wrong in queue task.", ex)
|
||||
|
||||
async def run_action(self, action):
|
||||
if action is None or action[0] is None:
|
||||
return
|
||||
|
||||
awaitable = action[0](*(action[1]), **(action[2]))
|
||||
if awaitable is not None:
|
||||
await awaitable
|
||||
|
||||
async def add_action_to_queue(self, action, *args, **kwargs):
|
||||
await self.queued_actions.put((action, args, kwargs))
|
||||
|
||||
async def set_idle_action(self, action, *args, **kwargs):
|
||||
self.idle_action = (action, args, kwargs)
|
||||
await self.queued_actions.put(None)
|
||||
|
|
@ -1,20 +1,22 @@
|
|||
import Adafruit_DHT
|
||||
|
||||
class Dht22Sensor:
|
||||
def __init__(self, pin):
|
||||
|
||||
class Dht22Climate:
|
||||
|
||||
def __init__(self, pin: int):
|
||||
self.sensor = Adafruit_DHT.AM2302
|
||||
self.pin = pin
|
||||
self.last_read = None
|
||||
|
||||
def read(self):
|
||||
def get_last_read(self) -> dict | None:
|
||||
if self.last_read is None:
|
||||
return self.read()
|
||||
return self.last_read
|
||||
|
||||
def read(self) -> dict | None:
|
||||
humidity, temperature = Adafruit_DHT.read_retry(self.sensor, self.pin)
|
||||
if humidity is not None and temperature is not None:
|
||||
self.last_read = {'temperature': temperature, 'humidity': humidity}
|
||||
self.last_read = {"temperature": temperature, "humidity": humidity}
|
||||
return self.last_read
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_last_read(self):
|
||||
if self.last_read is None:
|
||||
self.read()
|
||||
return self.last_read
|
|
@ -1,3 +1,4 @@
|
|||
import asyncio
|
||||
import time
|
||||
from datetime import datetime
|
||||
from luma.core.interface.serial import spi, noop
|
||||
|
@ -5,7 +6,7 @@ from luma.core.render import canvas
|
|||
from luma.led_matrix.device import max7219
|
||||
from luma.core import legacy
|
||||
from luma.core.virtual import viewport
|
||||
from luma.core.legacy.font import proportional, CP437_FONT, LCD_FONT
|
||||
from luma.core.legacy.font import proportional, CP437_FONT
|
||||
|
||||
|
||||
class MatrixDisplay:
|
||||
|
@ -19,7 +20,17 @@ class MatrixDisplay:
|
|||
|
||||
self.device.contrast(self.contrast)
|
||||
|
||||
def set_contrast(self, contrast: int):
|
||||
"""Set contrast for all actions.
|
||||
|
||||
Args:
|
||||
contrast (int): [0, 255]
|
||||
"""
|
||||
self.contrast = contrast
|
||||
self.device.contrast(self.contrast)
|
||||
|
||||
def show_text(self, text):
|
||||
self.device.contrast(self.contrast)
|
||||
width = len(text) * 8 + 4 * 8
|
||||
virtual = viewport(self.device, width + 4 * 8, height=8)
|
||||
with canvas(virtual) as draw:
|
||||
|
@ -31,8 +42,12 @@ class MatrixDisplay:
|
|||
virtual.set_position((offset, 0))
|
||||
time.sleep(self.text_speed)
|
||||
|
||||
def flash(self, count=1):
|
||||
self.device.contrast(255)
|
||||
def flash(self, count=1, contrast=None):
|
||||
if contrast:
|
||||
self.device.contrast(contrast)
|
||||
else:
|
||||
self.device.contrast(self.contrast)
|
||||
|
||||
while count > 0:
|
||||
with canvas(self.device) as draw:
|
||||
draw.rectangle((0, 0, 31, 7), outline="white", fill="white")
|
||||
|
@ -43,15 +58,29 @@ class MatrixDisplay:
|
|||
time.sleep(0.1)
|
||||
|
||||
count -= 1
|
||||
self.device.contrast(self.contrast)
|
||||
|
||||
def turn_off(self):
|
||||
self.device.contrast(0)
|
||||
with canvas(self.device) as draw:
|
||||
draw.rectangle((0, 0, 31, 7), outline="black", fill="black")
|
||||
|
||||
def turn_full(self):
|
||||
self.device.contrast(self.contrast)
|
||||
with canvas(self.device) as draw:
|
||||
draw.rectangle((0, 0, 31, 7), outline="white", fill="white")
|
||||
|
||||
async def pattern(self, pattern: str = "01", step_ms: int = 500):
|
||||
# Parse
|
||||
pattern_steps = [step.strip() == "1" for step in pattern]
|
||||
# Execute
|
||||
for step in pattern_steps:
|
||||
if step:
|
||||
self.turn_full()
|
||||
else:
|
||||
self.turn_off()
|
||||
await asyncio.sleep(step_ms / 1000)
|
||||
|
||||
def show_current_time(self):
|
||||
self.device.contrast(self.contrast)
|
||||
hour = str(datetime.now().hour).rjust(2, "0")
|
||||
minute = str(datetime.now().minute).rjust(2, "0")
|
||||
with canvas(self.device) as draw:
|
48
src/handler/mhz19_co2.py
Normal file
48
src/handler/mhz19_co2.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
import serial
|
||||
|
||||
|
||||
class Mhz19Co2:
|
||||
def __init__(self):
|
||||
self.last_read = None
|
||||
|
||||
self.serial_port = "/dev/serial0"
|
||||
self.baud_rate = 9600
|
||||
self.byte_size = 8
|
||||
self.parity = "N"
|
||||
self.stop_bits = 1
|
||||
self.timeout = None
|
||||
|
||||
def get_last_read(self) -> int | None:
|
||||
if self.last_read is None:
|
||||
return self.read()
|
||||
return self.last_read
|
||||
|
||||
def read(self) -> int | None:
|
||||
ser = None
|
||||
try:
|
||||
ser = serial.Serial(
|
||||
port=self.serial_port,
|
||||
baudrate=self.baud_rate,
|
||||
bytesize=self.byte_size,
|
||||
parity=self.parity,
|
||||
stopbits=self.stop_bits,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
# send "Read CO2" command
|
||||
command_data = bytes([0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79])
|
||||
ser.write(command_data)
|
||||
|
||||
# read "Return Value (CO2 concentration)"
|
||||
data = ser.read(9)
|
||||
concentration = data[2] * 256 + data[3]
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error reading data: {e}")
|
||||
finally:
|
||||
if ser:
|
||||
ser.close()
|
||||
ser = None
|
||||
|
||||
self.last_read = concentration
|
||||
return concentration
|
198
src/main.py
198
src/main.py
|
@ -1,20 +1,35 @@
|
|||
import os
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from datetime import datetime
|
||||
import requests
|
||||
from history import get_recent_entries
|
||||
from matrix import MatrixDisplay
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import asyncio
|
||||
from climate import Dht22Sensor
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from actions import (
|
||||
climate_sensor,
|
||||
display_time,
|
||||
matrix_display,
|
||||
display_pattern,
|
||||
co2_sensor,
|
||||
)
|
||||
from config import climate_log_file
|
||||
from handler.action_queue import ActionQueue
|
||||
from handler.history import get_recent_entries
|
||||
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
|
||||
|
||||
# Start services
|
||||
queue = ActionQueue(matrix_display.show_current_time)
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
origins = [
|
||||
"http://localhost",
|
||||
"http://localhost:8000",
|
||||
"http://raspberrypi",
|
||||
"http://192.168.178.84"
|
||||
"http://192.168.178.84:8000",
|
||||
"http://192.168.178.84",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
|
@ -25,110 +40,75 @@ app.add_middleware(
|
|||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
should_run_time_loop = True
|
||||
dht22_pin = 17
|
||||
climate_log_file = "./climate.csv"
|
||||
|
||||
matrix_display = MatrixDisplay()
|
||||
dht22_sensor = Dht22Sensor(dht22_pin)
|
||||
|
||||
# Start background service to log temperature and humidity every minute
|
||||
async def log_temperature():
|
||||
# If file does not exist, create it and write header
|
||||
if not os.path.isfile(climate_log_file):
|
||||
with open(climate_log_file, "w") as f:
|
||||
f.write("timestamp,temperature,humidity\n")
|
||||
|
||||
while True:
|
||||
measurements = dht22_sensor.read()
|
||||
if measurements is not None:
|
||||
with open(climate_log_file, "a") as f:
|
||||
f.write("{},{},{}\n".format(
|
||||
datetime.now().isoformat(),
|
||||
measurements["temperature"],
|
||||
measurements["humidity"]
|
||||
))
|
||||
await asyncio.sleep(60)
|
||||
|
||||
async def display_time():
|
||||
while should_run_time_loop:
|
||||
try:
|
||||
matrix_display.show_current_time()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to display time on the matrix display: {e}",
|
||||
)
|
||||
|
||||
seconds_until_next_minute = 60 - datetime.now().second
|
||||
await asyncio.sleep(seconds_until_next_minute)
|
||||
|
||||
|
||||
|
||||
asyncio.create_task(display_time())
|
||||
asyncio.create_task(log_temperature())
|
||||
|
||||
|
||||
|
||||
@app.post("/time")
|
||||
async def start_time_loop():
|
||||
global should_run_time_loop
|
||||
should_run_time_loop = True
|
||||
asyncio.create_task(display_time())
|
||||
await queue.set_idle_action(display_time)
|
||||
return {"message": "Time loop started"}
|
||||
|
||||
|
||||
@app.post("/full")
|
||||
async def turn_full():
|
||||
await queue.set_idle_action(matrix_display.turn_full)
|
||||
return {"message": "Full screen turned on"}
|
||||
|
||||
|
||||
@app.post("/off")
|
||||
async def turn_off():
|
||||
global should_run_time_loop
|
||||
should_run_time_loop = False
|
||||
matrix_display.turn_off()
|
||||
await queue.set_idle_action(matrix_display.turn_off)
|
||||
return {"message": "Display turned off"}
|
||||
|
||||
|
||||
|
||||
@app.post("/temperature")
|
||||
async def temperature():
|
||||
measurements = dht22_sensor.get_last_read()
|
||||
measurements = climate_sensor.read()
|
||||
if measurements is None:
|
||||
return {"message": "Failed to read temperature"}
|
||||
|
||||
|
||||
global should_run_time_loop
|
||||
was_clock_runnign = should_run_time_loop
|
||||
should_run_time_loop = False
|
||||
|
||||
|
||||
matrix_display.show_text("{0:0.1f}*C".format(measurements["temperature"]))
|
||||
|
||||
if was_clock_runnign:
|
||||
should_run_time_loop = True
|
||||
asyncio.create_task(display_time())
|
||||
await queue.add_action_to_queue(
|
||||
matrix_display.show_text, "{0:0.1f}*C".format(measurements["temperature"])
|
||||
)
|
||||
|
||||
return measurements
|
||||
|
||||
|
||||
@app.post("/humidity")
|
||||
async def humidity():
|
||||
measurements = dht22_sensor.get_last_read()
|
||||
measurements = climate_sensor.read()
|
||||
if measurements is None:
|
||||
return {"message": "Failed to read humidity"}
|
||||
|
||||
|
||||
global should_run_time_loop
|
||||
was_clock_runnign = should_run_time_loop
|
||||
should_run_time_loop = False
|
||||
|
||||
|
||||
matrix_display.show_text("{0:0.1f}%".format(measurements["humidity"]))
|
||||
|
||||
if was_clock_runnign:
|
||||
should_run_time_loop = True
|
||||
asyncio.create_task(display_time())
|
||||
await queue.add_action_to_queue(
|
||||
matrix_display.show_text, "{0:0.1f}%".format(measurements["humidity"])
|
||||
)
|
||||
|
||||
return measurements
|
||||
|
||||
|
||||
@app.post("/co2")
|
||||
async def co2():
|
||||
co2 = co2_sensor.read()
|
||||
if co2 is None:
|
||||
return {"message": "Failed to read co2"}
|
||||
|
||||
await queue.add_action_to_queue(matrix_display.show_text, f"{co2} ppm")
|
||||
|
||||
return {"co2": co2}
|
||||
|
||||
|
||||
@app.post("/climate")
|
||||
async def climate():
|
||||
measurements = climate_sensor.read()
|
||||
if measurements is None:
|
||||
return {"message": "Failed to read humidy and temperature"}
|
||||
|
||||
co2 = co2_sensor.read()
|
||||
if co2 is None:
|
||||
return {"message": "Failed to read co2"}
|
||||
|
||||
return {"co2": co2, **measurements}
|
||||
|
||||
|
||||
@app.post("/history")
|
||||
async def history():
|
||||
day_entry_count = 24 * 60
|
||||
|
@ -136,41 +116,29 @@ async def history():
|
|||
|
||||
|
||||
@app.post("/flash")
|
||||
async def flash(count: int = 1):
|
||||
global should_run_time_loop
|
||||
was_clock_runnign = should_run_time_loop
|
||||
should_run_time_loop = False
|
||||
|
||||
matrix_display.flash(count)
|
||||
|
||||
if was_clock_runnign:
|
||||
should_run_time_loop = True
|
||||
asyncio.create_task(display_time())
|
||||
async def flash(count: int = 1, contrast: Optional[int] = None):
|
||||
await queue.add_action_to_queue(
|
||||
matrix_display.flash, count=count, contrast=contrast
|
||||
)
|
||||
return {"message": "Display flashed"}
|
||||
|
||||
|
||||
@app.post("/pattern")
|
||||
async def flash(pattern: str = "01", step_ms: int = 500):
|
||||
await queue.set_idle_action(display_pattern, pattern=pattern, step_ms=step_ms)
|
||||
return {"message": "Activated pattern."}
|
||||
|
||||
|
||||
@app.post("/contrast")
|
||||
async def contrast(contrast: Optional[int] = None):
|
||||
if contrast:
|
||||
matrix_display.set_contrast(contrast)
|
||||
|
||||
return {"contrast": matrix_display.contrast}
|
||||
|
||||
|
||||
@app.post("/message")
|
||||
async def display_message(body: dict):
|
||||
global should_run_time_loop
|
||||
was_clock_runnign = should_run_time_loop
|
||||
should_run_time_loop = False
|
||||
message_text = body.get("message")
|
||||
try:
|
||||
matrix_display.show_text(message_text)
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to display message on the matrix display: {e}",
|
||||
)
|
||||
finally:
|
||||
if was_clock_runnign:
|
||||
should_run_time_loop = True
|
||||
asyncio.create_task(display_time())
|
||||
await queue.add_action_to_queue(matrix_display.show_text, message_text)
|
||||
return {"message": "Message displayed"}
|
||||
|
||||
|
||||
@app.post("/stop")
|
||||
async def stop_time_loop():
|
||||
global should_run_time_loop
|
||||
should_run_time_loop = False
|
||||
return {"message": "Time loop stopped"}
|
||||
|
|
3
start.sh
3
start.sh
|
@ -1,3 +0,0 @@
|
|||
cd /home/pi/matrix-clock/src
|
||||
|
||||
uvicorn main:app --reload --host 0.0.0.0
|
Loading…
Reference in a new issue