mirror of
https://github.com/pimoroni/grow-python
synced 2025-10-25 15:19:23 +00:00
1141 lines
33 KiB
Python
1141 lines
33 KiB
Python
#!/usr/bin/env python3
|
|
import logging
|
|
import math
|
|
import pathlib
|
|
import random
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
import ltr559
|
|
import RPi.GPIO as GPIO
|
|
import ST7735
|
|
from fonts.ttf import RobotoMedium as UserFont
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
import yaml
|
|
from grow import Piezo
|
|
from grow.moisture import Moisture
|
|
from grow.pump import Pump
|
|
|
|
|
|
FPS = 10
|
|
|
|
BUTTONS = [5, 6, 16, 24]
|
|
LABELS = ["A", "B", "X", "Y"]
|
|
|
|
DISPLAY_WIDTH = 160
|
|
DISPLAY_HEIGHT = 80
|
|
|
|
COLOR_WHITE = (255, 255, 255)
|
|
COLOR_BLUE = (31, 137, 251)
|
|
COLOR_GREEN = (99, 255, 124)
|
|
COLOR_YELLOW = (254, 219, 82)
|
|
COLOR_RED = (247, 0, 63)
|
|
COLOR_BLACK = (0, 0, 0)
|
|
|
|
|
|
# Only the ALPHA channel is used from these images
|
|
icon_drop = Image.open("icons/icon-drop.png").convert("RGBA")
|
|
icon_nodrop = Image.open("icons/icon-nodrop.png").convert("RGBA")
|
|
icon_rightarrow = Image.open("icons/icon-rightarrow.png").convert("RGBA")
|
|
icon_alarm = Image.open("icons/icon-alarm.png").convert("RGBA")
|
|
icon_snooze = Image.open("icons/icon-snooze.png").convert("RGBA")
|
|
icon_help = Image.open("icons/icon-help.png").convert("RGBA")
|
|
icon_settings = Image.open("icons/icon-settings.png").convert("RGBA")
|
|
icon_channel = Image.open("icons/icon-channel.png").convert("RGBA")
|
|
icon_backdrop = Image.open("icons/icon-backdrop.png").convert("RGBA")
|
|
icon_return = Image.open("icons/icon-return.png").convert("RGBA")
|
|
|
|
|
|
class View:
|
|
def __init__(self, image):
|
|
self._image = image
|
|
self._draw = ImageDraw.Draw(image)
|
|
|
|
self.font = ImageFont.truetype(UserFont, 14)
|
|
self.font_small = ImageFont.truetype(UserFont, 10)
|
|
|
|
def button_a(self):
|
|
return False
|
|
|
|
def button_b(self):
|
|
return False
|
|
|
|
def button_x(self):
|
|
return False
|
|
|
|
def button_y(self):
|
|
return False
|
|
|
|
def update(self):
|
|
pass
|
|
|
|
def render(self):
|
|
pass
|
|
|
|
def clear(self):
|
|
self._draw.rectangle((0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT), (0, 0, 0))
|
|
|
|
def icon(self, icon, position, color):
|
|
col = Image.new("RGBA", icon.size, color=color)
|
|
self._image.paste(col, position, mask=icon)
|
|
|
|
def label(
|
|
self,
|
|
position="X",
|
|
text=None,
|
|
bgcolor=(0, 0, 0),
|
|
textcolor=(255, 255, 255),
|
|
margin=4,
|
|
):
|
|
if position not in ["A", "B", "X", "Y"]:
|
|
raise ValueError(f"Invalid label position {position}")
|
|
|
|
text_w, text_h = self._draw.textsize(text, font=self.font)
|
|
text_h = 11
|
|
text_w += margin * 2
|
|
text_h += margin * 2
|
|
|
|
if position == "A":
|
|
x, y = 0, 0
|
|
if position == "B":
|
|
x, y = 0, DISPLAY_HEIGHT - text_h
|
|
if position == "X":
|
|
x, y = DISPLAY_WIDTH - text_w, 0
|
|
if position == "Y":
|
|
x, y = DISPLAY_WIDTH - text_w, DISPLAY_HEIGHT - text_h
|
|
|
|
x2, y2 = x + text_w, y + text_h
|
|
|
|
self._draw.rectangle((x, y, x2, y2), bgcolor)
|
|
self._draw.text(
|
|
(x + margin, y + margin - 1), text, font=self.font, fill=textcolor
|
|
)
|
|
|
|
def overlay(self, text, top=0):
|
|
"""Draw an overlay with some auto-sized text."""
|
|
self._draw.rectangle(
|
|
(0, top, DISPLAY_WIDTH, DISPLAY_HEIGHT), fill=(192, 225, 254)
|
|
) # Overlay backdrop
|
|
self._draw.rectangle((0, top, DISPLAY_WIDTH, top + 1), fill=COLOR_BLUE) # Top border
|
|
self.text_in_rect(
|
|
text,
|
|
self.font,
|
|
(3, top, DISPLAY_WIDTH - 3, DISPLAY_HEIGHT - 2),
|
|
line_spacing=1,
|
|
)
|
|
|
|
def text_in_rect(self, text, font, rect, line_spacing=1.1, textcolor=(0, 0, 0)):
|
|
x1, y1, x2, y2 = rect
|
|
width = x2 - x1
|
|
height = y2 - y1
|
|
|
|
# Given a rectangle, reflow and scale text to fit, centred
|
|
while font.size > 0:
|
|
space_width = font.getsize(" ")[0]
|
|
line_height = int(font.size * line_spacing)
|
|
max_lines = math.floor(height / line_height)
|
|
lines = []
|
|
|
|
# Determine if text can fit at current scale.
|
|
words = text.split(" ")
|
|
|
|
while len(lines) < max_lines and len(words) > 0:
|
|
line = []
|
|
|
|
while (
|
|
len(words) > 0
|
|
and font.getsize(" ".join(line + [words[0]]))[0] <= width
|
|
):
|
|
line.append(words.pop(0))
|
|
|
|
lines.append(" ".join(line))
|
|
|
|
if len(lines) <= max_lines and len(words) == 0:
|
|
# Solution is found, render the text.
|
|
y = int(
|
|
y1
|
|
+ (height / 2)
|
|
- (len(lines) * line_height / 2)
|
|
- (line_height - font.size) / 2
|
|
)
|
|
|
|
bounds = [x2, y, x1, y + len(lines) * line_height]
|
|
|
|
for line in lines:
|
|
line_width = font.getsize(line)[0]
|
|
x = int(x1 + (width / 2) - (line_width / 2))
|
|
bounds[0] = min(bounds[0], x)
|
|
bounds[2] = max(bounds[2], x + line_width)
|
|
self._draw.text((x, y), line, font=self.font, fill=textcolor)
|
|
y += line_height
|
|
|
|
return tuple(bounds)
|
|
|
|
font = ImageFont.truetype(font.path, font.size - 1)
|
|
|
|
|
|
class MainView(View):
|
|
"""Main overview.
|
|
|
|
Displays three channels and alarm indicator/snooze.
|
|
|
|
"""
|
|
|
|
def __init__(self, image, channels=None, alarm=None):
|
|
self.channels = channels
|
|
self.alarm = alarm
|
|
|
|
View.__init__(self, image)
|
|
|
|
def render_channel(self, channel):
|
|
bar_x = 33
|
|
bar_margin = 2
|
|
bar_width = 30
|
|
label_width = 16
|
|
label_height = 16
|
|
label_y = 0
|
|
|
|
x = [
|
|
bar_x,
|
|
bar_x + ((bar_width + bar_margin) * 1),
|
|
bar_x + ((bar_width + bar_margin) * 2),
|
|
][channel.channel - 1]
|
|
|
|
# Saturation amounts from each sensor
|
|
saturation = channel.sensor.saturation
|
|
active = channel.sensor.active and channel.enabled
|
|
warn_level = channel.warn_level
|
|
|
|
if active:
|
|
# Draw background bars
|
|
self._draw.rectangle(
|
|
(x, int((1.0 - saturation) * DISPLAY_HEIGHT), x + bar_width - 1, DISPLAY_HEIGHT),
|
|
channel.indicator_color(saturation) if active else (229, 229, 229),
|
|
)
|
|
|
|
y = int((1.0 - warn_level) * DISPLAY_HEIGHT)
|
|
self._draw.rectangle(
|
|
(x, y, x + bar_width - 1, y), (255, 0, 0) if channel.alarm else (0, 0, 0)
|
|
)
|
|
|
|
# Channel selection icons
|
|
x += (bar_width - label_width) // 2
|
|
|
|
self.icon(icon_channel, (x, label_y), (200, 200, 200) if active else (64, 64, 64))
|
|
|
|
# TODO: replace number text with graphic
|
|
tw, th = self.font.getsize(str(channel.channel))
|
|
self._draw.text(
|
|
(x + int(math.ceil(8 - (tw / 2.0))), label_y + 1),
|
|
str(channel.channel),
|
|
font=self.font,
|
|
fill=(55, 55, 55) if active else (100, 100, 100),
|
|
)
|
|
|
|
def render(self):
|
|
self.clear()
|
|
|
|
for channel in self.channels:
|
|
self.render_channel(channel)
|
|
|
|
# Icons
|
|
self.icon(icon_backdrop, (0, 0), COLOR_WHITE)
|
|
self.icon(icon_rightarrow, (3, 3), (55, 55, 55))
|
|
|
|
self.alarm.render((3, DISPLAY_HEIGHT - 23))
|
|
|
|
self.icon(icon_backdrop.rotate(180), (DISPLAY_WIDTH - 26, 0), COLOR_WHITE)
|
|
self.icon(icon_settings, (DISPLAY_WIDTH - 19 - 3, 3), (55, 55, 55))
|
|
|
|
|
|
class EditView(View):
|
|
"""Baseclass for a settings edit view."""
|
|
|
|
def __init__(self, image, options=[]):
|
|
self._options = options
|
|
self._current_option = 0
|
|
self._change_mode = False
|
|
self._help_mode = False
|
|
self.channel = None
|
|
|
|
View.__init__(self, image)
|
|
|
|
def render(self):
|
|
self.icon(icon_backdrop.rotate(180), (DISPLAY_WIDTH - 26, 0), COLOR_WHITE)
|
|
self.icon(icon_return, (DISPLAY_WIDTH - 19 - 3, 3), (55, 55, 55))
|
|
|
|
option = self._options[self._current_option]
|
|
title = option["title"]
|
|
prop = option["prop"]
|
|
object = option.get("object", self.channel)
|
|
value = getattr(object, prop)
|
|
text = option["format"](value)
|
|
mode = option.get("mode", "int")
|
|
help = option["help"]
|
|
|
|
if self._change_mode:
|
|
self.label(
|
|
"Y",
|
|
"Yes" if mode == "bool" else "++",
|
|
textcolor=COLOR_BLACK,
|
|
bgcolor=COLOR_WHITE,
|
|
)
|
|
self.label(
|
|
"B",
|
|
"No" if mode == "bool" else "--",
|
|
textcolor=COLOR_BLACK,
|
|
bgcolor=COLOR_WHITE,
|
|
)
|
|
else:
|
|
self.label("B", "Next", textcolor=COLOR_BLACK, bgcolor=COLOR_WHITE)
|
|
self.label("Y", "Change", textcolor=COLOR_BLACK, bgcolor=COLOR_WHITE)
|
|
|
|
self._draw.text((3, 36), f"{title} : {text}", font=self.font, fill=COLOR_WHITE)
|
|
|
|
if self._help_mode:
|
|
self.icon(icon_backdrop.rotate(90), (0, 0), COLOR_BLUE)
|
|
self._draw.rectangle((7, 3, 23, 19), COLOR_BLACK)
|
|
self.overlay(help, top=26)
|
|
|
|
self.icon(icon_help, (0, 0), COLOR_BLUE)
|
|
|
|
def button_a(self):
|
|
self._help_mode = not self._help_mode
|
|
return True
|
|
|
|
def button_b(self):
|
|
if self._help_mode:
|
|
return True
|
|
|
|
if self._change_mode:
|
|
option = self._options[self._current_option]
|
|
prop = option["prop"]
|
|
mode = option.get("mode", "int")
|
|
object = option.get("object", self.channel)
|
|
|
|
value = getattr(object, prop)
|
|
if mode == "bool":
|
|
value = False
|
|
else:
|
|
inc = option["inc"]
|
|
limit = option["min"]
|
|
value -= inc
|
|
if mode == "float":
|
|
value = round(value, option.get("round", 1))
|
|
if value < limit:
|
|
value = limit
|
|
setattr(object, prop, value)
|
|
else:
|
|
self._current_option += 1
|
|
self._current_option %= len(self._options)
|
|
|
|
return True
|
|
|
|
def button_x(self):
|
|
if self._change_mode:
|
|
self._change_mode = False
|
|
return True
|
|
return False
|
|
|
|
def button_y(self):
|
|
if self._help_mode:
|
|
return True
|
|
if self._change_mode:
|
|
option = self._options[self._current_option]
|
|
prop = option["prop"]
|
|
mode = option.get("mode", "int")
|
|
object = option.get("object", self.channel)
|
|
|
|
value = getattr(object, prop)
|
|
if mode == "bool":
|
|
value = True
|
|
else:
|
|
inc = option["inc"]
|
|
limit = option["max"]
|
|
value += inc
|
|
if mode == "float":
|
|
value = round(value, option.get("round", 1))
|
|
if value > limit:
|
|
value = limit
|
|
setattr(object, prop, value)
|
|
else:
|
|
self._change_mode = True
|
|
|
|
return True
|
|
|
|
|
|
class SettingsView(EditView):
|
|
"""Main settings."""
|
|
|
|
def __init__(self, image, options=[]):
|
|
EditView.__init__(self, image, options)
|
|
|
|
def render(self):
|
|
self.clear()
|
|
self._draw.text(
|
|
(28, 5),
|
|
"Settings",
|
|
font=self.font,
|
|
fill=COLOR_WHITE,
|
|
)
|
|
EditView.render(self)
|
|
|
|
|
|
class ChannelView(View):
|
|
"""Base class for a view that deals with a specific channel instance."""
|
|
|
|
def __init__(self, image, channel=None):
|
|
self.channel = channel
|
|
View.__init__(self, image)
|
|
|
|
def draw_status(self, position):
|
|
status = f"Sat: {self.channel.sensor.saturation * 100:.2f}%"
|
|
|
|
self._draw.text(
|
|
position,
|
|
status,
|
|
font=self.font,
|
|
fill=(255, 255, 255),
|
|
)
|
|
|
|
def draw_context(self, position, metric="Hz"):
|
|
context = f"Now: {self.channel.sensor.moisture:.2f}Hz"
|
|
if metric.lower() == "sat":
|
|
context = f"Now: {self.channel.sensor.saturation * 100:.2f}%"
|
|
|
|
self._draw.text(
|
|
position,
|
|
context,
|
|
font=self.font,
|
|
fill=(255, 255, 255),
|
|
)
|
|
|
|
|
|
class DetailView(ChannelView):
|
|
"""Single channel details.
|
|
|
|
Draw the channel graph and status line.
|
|
|
|
"""
|
|
|
|
def render(self):
|
|
self.clear()
|
|
|
|
if self.channel.enabled:
|
|
graph_height = DISPLAY_HEIGHT - 8 - 20
|
|
graph_width = DISPLAY_WIDTH - 64
|
|
|
|
graph_x = (DISPLAY_WIDTH - graph_width) // 2
|
|
graph_y = 8
|
|
|
|
self.draw_status((graph_x, graph_y + graph_height + 4))
|
|
|
|
self._draw.rectangle((graph_x, graph_y, graph_x + graph_width, graph_y + graph_height), (50, 50, 50))
|
|
|
|
for x, value in enumerate(self.channel.sensor.history[:graph_width]):
|
|
color = self.channel.indicator_color(value)
|
|
h = value * graph_height
|
|
x = graph_x + graph_width - x - 1
|
|
self._draw.rectangle((x, graph_y + graph_height - h, x + 1, graph_y + graph_height), color)
|
|
|
|
alarm_line = int(self.channel.warn_level * graph_height)
|
|
r = 255
|
|
if self.channel.alarm:
|
|
r = int(((math.sin(time.time() * 3 * math.pi) + 1.0) / 2.0) * 128) + 127
|
|
|
|
self._draw.rectangle(
|
|
(
|
|
0,
|
|
graph_height + 8 - alarm_line,
|
|
DISPLAY_WIDTH - 40,
|
|
graph_height + 8 - alarm_line,
|
|
),
|
|
(r, 0, 0),
|
|
)
|
|
self._draw.rectangle(
|
|
(
|
|
DISPLAY_WIDTH - 20,
|
|
graph_height + 8 - alarm_line,
|
|
DISPLAY_WIDTH,
|
|
graph_height + 8 - alarm_line,
|
|
),
|
|
(r, 0, 0),
|
|
)
|
|
|
|
self.icon(
|
|
icon_alarm,
|
|
(DISPLAY_WIDTH - 40, graph_height + 8 - alarm_line - 10),
|
|
(r, 0, 0),
|
|
)
|
|
|
|
# Channel icons
|
|
|
|
x_positions = [40, 72, 104]
|
|
label_x = x_positions[self.channel.channel - 1]
|
|
label_y = 0
|
|
|
|
active = self.channel.sensor.active and self.channel.enabled
|
|
|
|
for x in x_positions:
|
|
self.icon(icon_channel, (x, label_y - 10), (16, 16, 16))
|
|
|
|
self.icon(icon_channel, (label_x, label_y), (200, 200, 200))
|
|
|
|
tw, th = self.font.getsize(str(self.channel.channel))
|
|
self._draw.text(
|
|
(label_x + int(math.ceil(8 - (tw / 2.0))), label_y + 1),
|
|
str(self.channel.channel),
|
|
font=self.font,
|
|
fill=(55, 55, 55) if active else (100, 100, 100),
|
|
)
|
|
|
|
# Next button
|
|
self.icon(icon_backdrop, (0, 0), COLOR_WHITE)
|
|
self.icon(icon_rightarrow, (3, 3), (55, 55, 55))
|
|
|
|
# Prev button
|
|
# self.icon(icon_backdrop, (0, DISPLAY_HEIGHT - 26), COLOR_WHITE)
|
|
# self.icon(icon_return, (3, DISPLAY_HEIGHT - 26 + 3), (55, 55, 55))
|
|
|
|
# Edit
|
|
self.icon(icon_backdrop.rotate(180), (DISPLAY_WIDTH - 26, 0), COLOR_WHITE)
|
|
self.icon(icon_settings, (DISPLAY_WIDTH - 19 - 3, 3), (55, 55, 55))
|
|
|
|
|
|
class ChannelEditView(ChannelView, EditView):
|
|
"""Single channel edit."""
|
|
|
|
def __init__(self, image, channel=None):
|
|
options = [
|
|
{
|
|
"title": "Alarm Level",
|
|
"prop": "warn_level",
|
|
"inc": 0.05,
|
|
"min": 0,
|
|
"max": 1.0,
|
|
"mode": "float",
|
|
"round": 2,
|
|
"format": lambda value: f"{value * 100:0.2f}%",
|
|
"help": "Saturation at which alarm is triggered",
|
|
"context": "sat",
|
|
},
|
|
{
|
|
"title": "Enabled",
|
|
"prop": "enabled",
|
|
"mode": "bool",
|
|
"format": lambda value: "Yes" if value else "No",
|
|
"help": "Enable/disable this channel",
|
|
},
|
|
{
|
|
"title": "Watering Level",
|
|
"prop": "water_level",
|
|
"inc": 0.05,
|
|
"min": 0,
|
|
"max": 1.0,
|
|
"mode": "float",
|
|
"round": 2,
|
|
"format": lambda value: f"{value * 100:0.2f}%",
|
|
"help": "Saturation at which watering occurs",
|
|
"context": "sat",
|
|
},
|
|
{
|
|
"title": "Auto Water",
|
|
"prop": "auto_water",
|
|
"mode": "bool",
|
|
"format": lambda value: "Yes" if value else "No",
|
|
"help": "Enable/disable watering",
|
|
},
|
|
{
|
|
"title": "Wet Point",
|
|
"prop": "wet_point",
|
|
"inc": 0.5,
|
|
"min": 1,
|
|
"max": 27,
|
|
"mode": "float",
|
|
"round": 2,
|
|
"format": lambda value: f"{value:0.2f}Hz",
|
|
"help": "Frequency for fully saturated soil",
|
|
"context": "hz",
|
|
},
|
|
{
|
|
"title": "Dry Point",
|
|
"prop": "dry_point",
|
|
"inc": 0.5,
|
|
"min": 1,
|
|
"max": 27,
|
|
"mode": "float",
|
|
"round": 2,
|
|
"format": lambda value: f"{value:0.2f}Hz",
|
|
"help": "Frequency for fully dried soil",
|
|
"context": "hz",
|
|
},
|
|
{
|
|
"title": "Pump Time",
|
|
"prop": "pump_time",
|
|
"inc": 0.05,
|
|
"min": 0.05,
|
|
"max": 2.0,
|
|
"mode": "float",
|
|
"round": 2,
|
|
"format": lambda value: f"{value:0.2f}sec",
|
|
"help": "Time to run pump"
|
|
},
|
|
{
|
|
"title": "Pump Speed",
|
|
"prop": "pump_speed",
|
|
"inc": 0.05,
|
|
"min": 0.05,
|
|
"max": 1.0,
|
|
"mode": "float",
|
|
"round": 2,
|
|
"format": lambda value: f"{value*100:0.0f}%",
|
|
"help": "Speed of pump"
|
|
},
|
|
{
|
|
"title": "Watering Delay",
|
|
"prop": "watering_delay",
|
|
"inc": 10,
|
|
"min": 30,
|
|
"max": 500,
|
|
"mode": "int",
|
|
"format": lambda value: f"{value:0.0f}sec",
|
|
"help": "Delay between waterings"
|
|
},
|
|
|
|
]
|
|
EditView.__init__(self, image, options)
|
|
ChannelView.__init__(self, image, channel)
|
|
|
|
def render(self):
|
|
self.clear()
|
|
|
|
EditView.render(self)
|
|
|
|
option = self._options[self._current_option]
|
|
if "context" in option:
|
|
self.draw_context((34, 6), option["context"])
|
|
|
|
|
|
class Channel:
|
|
colors = [
|
|
COLOR_BLUE,
|
|
COLOR_GREEN,
|
|
COLOR_YELLOW,
|
|
COLOR_RED
|
|
]
|
|
|
|
def __init__(
|
|
self,
|
|
display_channel,
|
|
sensor_channel,
|
|
pump_channel,
|
|
title=None,
|
|
water_level=0.5,
|
|
warn_level=0.5,
|
|
pump_speed=0.5,
|
|
pump_time=0.2,
|
|
watering_delay=60,
|
|
wet_point=0.7,
|
|
dry_point=26.7,
|
|
icon=None,
|
|
auto_water=False,
|
|
enabled=False,
|
|
):
|
|
self.channel = display_channel
|
|
self.sensor = Moisture(sensor_channel)
|
|
self.pump = Pump(pump_channel)
|
|
self.water_level = water_level
|
|
self.warn_level = warn_level
|
|
self.auto_water = auto_water
|
|
self.pump_speed = pump_speed
|
|
self.pump_time = pump_time
|
|
self.watering_delay = watering_delay
|
|
self._wet_point = wet_point
|
|
self._dry_point = dry_point
|
|
self.last_dose = time.time()
|
|
self.icon = icon
|
|
self._enabled = enabled
|
|
self.alarm = False
|
|
self.title = f"Channel {display_channel}" if title is None else title
|
|
|
|
self.sensor.set_wet_point(wet_point)
|
|
self.sensor.set_dry_point(dry_point)
|
|
|
|
@property
|
|
def enabled(self):
|
|
return self._enabled
|
|
|
|
@enabled.setter
|
|
def enabled(self, enabled):
|
|
self._enabled = enabled
|
|
|
|
@property
|
|
def wet_point(self):
|
|
return self._wet_point
|
|
|
|
@property
|
|
def dry_point(self):
|
|
return self._dry_point
|
|
|
|
@wet_point.setter
|
|
def wet_point(self, wet_point):
|
|
self._wet_point = wet_point
|
|
self.sensor.set_wet_point(wet_point)
|
|
|
|
@dry_point.setter
|
|
def dry_point(self, dry_point):
|
|
self._dry_point = dry_point
|
|
self.sensor.set_dry_point(dry_point)
|
|
|
|
def warn_color(self):
|
|
value = self.sensor.moisture
|
|
|
|
def indicator_color(self, value):
|
|
value = 1.0 - value
|
|
|
|
if value == 1.0:
|
|
return self.colors[-1]
|
|
if value == 0.0:
|
|
return self.colors[0]
|
|
|
|
value *= len(self.colors) - 1
|
|
a = int(math.floor(value))
|
|
b = a + 1
|
|
blend = float(value - a)
|
|
|
|
r, g, b = [int(((self.colors[b][i] - self.colors[a][i]) * blend) + self.colors[a][i]) for i in range(3)]
|
|
|
|
return (r, g, b)
|
|
|
|
def update_from_yml(self, config):
|
|
if config is not None:
|
|
self.pump_speed = config.get("pump_speed", self.pump_speed)
|
|
self.pump_time = config.get("pump_time", self.pump_time)
|
|
self.warn_level = config.get("warn_level", self.warn_level)
|
|
self.water_level = config.get("water_level", self.water_level)
|
|
self.watering_delay = config.get("watering_delay", self.watering_delay)
|
|
self.auto_water = config.get("auto_water", self.auto_water)
|
|
self.enabled = config.get("enabled", self.enabled)
|
|
self.wet_point = config.get("wet_point", self.wet_point)
|
|
self.dry_point = config.get("dry_point", self.dry_point)
|
|
|
|
pass
|
|
|
|
def __str__(self):
|
|
return """Channel: {channel}
|
|
Enabled: {enabled}
|
|
Alarm level: {warn_level}
|
|
Auto water: {auto_water}
|
|
Water level: {water_level}
|
|
Pump speed: {pump_speed}
|
|
Pump time: {pump_time}
|
|
Delay: {watering_delay}
|
|
Wet point: {wet_point}
|
|
Dry point: {dry_point}
|
|
""".format(
|
|
channel=self.channel,
|
|
enabled=self.enabled,
|
|
warn_level=self.warn_level,
|
|
auto_water=self.auto_water,
|
|
water_level=self.water_level,
|
|
pump_speed=self.pump_speed,
|
|
pump_time=self.pump_time,
|
|
watering_delay=self.watering_delay,
|
|
wet_point=self.wet_point,
|
|
dry_point=self.dry_point,
|
|
)
|
|
|
|
def water(self):
|
|
if not self.auto_water:
|
|
return False
|
|
if time.time() - self.last_dose > self.watering_delay:
|
|
self.pump.dose(self.pump_speed, self.pump_time, blocking=False)
|
|
self.last_dose = time.time()
|
|
return True
|
|
return False
|
|
|
|
def render(self, image, font):
|
|
pass
|
|
|
|
def update(self):
|
|
if not self.enabled:
|
|
return
|
|
sat = self.sensor.saturation
|
|
if sat < self.water_level:
|
|
if self.water():
|
|
logging.info(
|
|
"Watering Channel: {} - rate {:.2f} for {:.2f}sec".format(
|
|
self.channel, self.pump_speed, self.pump_time
|
|
)
|
|
)
|
|
if sat < self.warn_level:
|
|
if not self.alarm:
|
|
logging.warning(
|
|
"Alarm on Channel: {} - saturation is {:.2f}% (warn level {:.2f}%)".format(
|
|
self.channel, sat * 100, self.warn_level * 100
|
|
)
|
|
)
|
|
self.alarm = True
|
|
else:
|
|
self.alarm = False
|
|
|
|
|
|
class Alarm(View):
|
|
def __init__(self, image, enabled=True, interval=10.0, beep_frequency=440):
|
|
self.piezo = Piezo()
|
|
self.enabled = enabled
|
|
self.interval = interval
|
|
self.beep_frequency = beep_frequency
|
|
self._triggered = False
|
|
self._time_last_beep = time.time()
|
|
self._sleep_until = None
|
|
|
|
View.__init__(self, image)
|
|
|
|
def update_from_yml(self, config):
|
|
if config is not None:
|
|
self.enabled = config.get("alarm_enable", self.enabled)
|
|
self.interval = config.get("alarm_interval", self.interval)
|
|
|
|
def update(self, lights_out=False):
|
|
if self._sleep_until is not None:
|
|
if self._sleep_until > time.time():
|
|
return
|
|
self._sleep_until = None
|
|
|
|
if (
|
|
self.enabled
|
|
and not lights_out
|
|
and self._triggered
|
|
and time.time() - self._time_last_beep > self.interval
|
|
):
|
|
self.piezo.beep(self.beep_frequency, 0.1, blocking=False)
|
|
threading.Timer(
|
|
0.3,
|
|
self.piezo.beep,
|
|
args=[self.beep_frequency, 0.1],
|
|
kwargs={"blocking": False},
|
|
).start()
|
|
threading.Timer(
|
|
0.6,
|
|
self.piezo.beep,
|
|
args=[self.beep_frequency, 0.1],
|
|
kwargs={"blocking": False},
|
|
).start()
|
|
self._time_last_beep = time.time()
|
|
|
|
self._triggered = False
|
|
|
|
def render(self, position=(0, 0)):
|
|
x, y = position
|
|
# Draw the snooze icon- will be pulsing red if the alarm state is True
|
|
#self._draw.rectangle((x, y, x + 19, y + 19), (255, 255, 255))
|
|
r = 129
|
|
if self._triggered and self._sleep_until is None:
|
|
r = int(((math.sin(time.time() * 3 * math.pi) + 1.0) / 2.0) * 128) + 127
|
|
|
|
if self._sleep_until is None:
|
|
self.icon(icon_alarm, (x, y - 1), (r, 129, 129))
|
|
else:
|
|
self.icon(icon_snooze, (x, y - 1), (r, 129, 129))
|
|
|
|
def trigger(self):
|
|
self._triggered = True
|
|
|
|
def disable(self):
|
|
self.enabled = False
|
|
|
|
def enable(self):
|
|
self.enabled = True
|
|
|
|
def cancel_sleep(self):
|
|
self._sleep_until = None
|
|
|
|
def sleeping(self):
|
|
return self._sleep_until is not None
|
|
|
|
def sleep(self, duration=500):
|
|
self._sleep_until = time.time() + duration
|
|
|
|
|
|
class ViewController:
|
|
def __init__(self, views):
|
|
self.views = views
|
|
self._current_view = 0
|
|
self._current_subview = 0
|
|
|
|
@property
|
|
def home(self):
|
|
return self._current_view == 0 and self._current_subview == 0
|
|
|
|
def next_subview(self):
|
|
view = self.views[self._current_view]
|
|
if isinstance(view, tuple):
|
|
self._current_subview += 1
|
|
self._current_subview %= len(view)
|
|
|
|
def next_view(self):
|
|
if self._current_subview == 0:
|
|
self._current_view += 1
|
|
self._current_view %= len(self.views)
|
|
self._current_subview = 0
|
|
|
|
def prev_view(self):
|
|
if self._current_subview == 0:
|
|
self._current_view -= 1
|
|
self._current_view %= len(self.views)
|
|
self._current_subview = 0
|
|
|
|
def get_current_view(self):
|
|
view = self.views[self._current_view]
|
|
if isinstance(view, tuple):
|
|
view = view[self._current_subview]
|
|
|
|
return view
|
|
|
|
@property
|
|
def view(self):
|
|
return self.get_current_view()
|
|
|
|
def update(self):
|
|
self.view.update()
|
|
|
|
def render(self):
|
|
self.view.render()
|
|
|
|
def button_a(self):
|
|
if not self.view.button_a():
|
|
self.next_view()
|
|
|
|
def button_b(self):
|
|
self.view.button_b()
|
|
|
|
def button_x(self):
|
|
if not self.view.button_x():
|
|
self.next_subview()
|
|
return True
|
|
return True
|
|
|
|
def button_y(self):
|
|
return self.view.button_y()
|
|
|
|
|
|
class Config:
|
|
def __init__(self):
|
|
self.config = None
|
|
self._last_save = ""
|
|
|
|
self.channel_settings = [
|
|
"enabled",
|
|
"warn_level",
|
|
"wet_point",
|
|
"dry_point",
|
|
"watering_delay",
|
|
"auto_water",
|
|
"pump_time",
|
|
"pump_speed"
|
|
]
|
|
|
|
self.general_settings = [
|
|
"alarm_enable",
|
|
"alarm_interval",
|
|
]
|
|
|
|
def load(self, settings_file="settings.yml"):
|
|
if len(sys.argv) > 1:
|
|
settings_file = sys.argv[1]
|
|
|
|
settings_file = pathlib.Path(settings_file)
|
|
|
|
if settings_file.is_file():
|
|
try:
|
|
self.config = yaml.safe_load(open(settings_file))
|
|
except yaml.parser.ParserError as e:
|
|
raise yaml.parser.ParserError(
|
|
"Error parsing settings file: {} ({})".format(settings_file, e)
|
|
)
|
|
|
|
def save(self, settings_file="settings.yml"):
|
|
if len(sys.argv) > 1:
|
|
settings_file = sys.argv[1]
|
|
|
|
settings_file = pathlib.Path(settings_file)
|
|
|
|
dump = yaml.dump(self.config)
|
|
|
|
if dump == self._last_save:
|
|
return
|
|
|
|
if settings_file.is_file():
|
|
with open(settings_file, "w") as file:
|
|
file.write(dump)
|
|
|
|
self._last_save = dump
|
|
|
|
def get_channel(self, channel_id):
|
|
return self.config.get("channel{}".format(channel_id), {})
|
|
|
|
def set(self, section, settings):
|
|
if isinstance(settings, dict):
|
|
self.config[section].update(settings)
|
|
else:
|
|
for key in self.channel_settings:
|
|
value = getattr(settings, key, None)
|
|
if value is not None:
|
|
self.config[section].update({key: value})
|
|
|
|
def set_channel(self, channel_id, settings):
|
|
self.set("channel{}".format(channel_id), settings)
|
|
|
|
def get_general(self):
|
|
return self.config.get("general", {})
|
|
|
|
def set_general(self, settings):
|
|
self.set("general", settings)
|
|
|
|
|
|
def main():
|
|
def handle_button(pin):
|
|
index = BUTTONS.index(pin)
|
|
label = LABELS[index]
|
|
|
|
if label == "A": # Select View
|
|
viewcontroller.button_a()
|
|
|
|
if label == "B": # Sleep Alarm
|
|
if not viewcontroller.button_b():
|
|
if viewcontroller.home:
|
|
if alarm.sleeping():
|
|
alarm.cancel_sleep()
|
|
else:
|
|
alarm.sleep()
|
|
|
|
if label == "X":
|
|
viewcontroller.button_x()
|
|
|
|
if label == "Y":
|
|
viewcontroller.button_y()
|
|
|
|
|
|
# Set up the ST7735 SPI Display
|
|
display = ST7735.ST7735(
|
|
port=0, cs=1, dc=9, backlight=12, rotation=270, spi_speed_hz=80000000
|
|
)
|
|
display.begin()
|
|
|
|
# Set up light sensor
|
|
light = ltr559.LTR559()
|
|
|
|
# Set up our canvas and prepare for drawing
|
|
image = Image.new("RGBA", (DISPLAY_WIDTH, DISPLAY_HEIGHT), color=(255, 255, 255))
|
|
|
|
# Pick a random selection of plant icons to display on screen
|
|
channels = [
|
|
Channel(1, 1, 1),
|
|
Channel(2, 2, 2),
|
|
Channel(3, 3, 3),
|
|
]
|
|
|
|
alarm = Alarm(image)
|
|
|
|
config = Config()
|
|
|
|
GPIO.setmode(GPIO.BCM)
|
|
GPIO.setwarnings(False)
|
|
GPIO.setup(BUTTONS, GPIO.IN, pull_up_down=GPIO.PUD_UP)
|
|
|
|
for pin in BUTTONS:
|
|
GPIO.add_event_detect(pin, GPIO.FALLING, handle_button, bouncetime=200)
|
|
|
|
config.load()
|
|
|
|
for channel in channels:
|
|
channel.update_from_yml(config.get_channel(channel.channel))
|
|
|
|
alarm.update_from_yml(config.get_general())
|
|
|
|
print("Channels:")
|
|
for channel in channels:
|
|
print(channel)
|
|
|
|
print(
|
|
"""Settings:
|
|
Alarm Enabled: {}
|
|
Alarm Interval: {:.2f}s
|
|
""".format(
|
|
alarm.enabled, alarm.interval
|
|
)
|
|
)
|
|
|
|
main_options = [
|
|
{
|
|
"title": "Alarm Interval",
|
|
"prop": "interval",
|
|
"inc": 1,
|
|
"min": 1,
|
|
"max": 60,
|
|
"format": lambda value: f"{value:02.0f}sec",
|
|
"object": alarm,
|
|
"help": "Time between alarm beeps.",
|
|
},
|
|
{
|
|
"title": "Alarm Enable",
|
|
"prop": "enabled",
|
|
"mode": "bool",
|
|
"format": lambda value: "Yes" if value else "No",
|
|
"object": alarm,
|
|
"help": "Enable the piezo alarm beep.",
|
|
},
|
|
]
|
|
|
|
viewcontroller = ViewController(
|
|
[
|
|
(
|
|
MainView(image, channels=channels, alarm=alarm),
|
|
SettingsView(image, options=main_options),
|
|
),
|
|
(
|
|
DetailView(image, channel=channels[0]),
|
|
ChannelEditView(image, channel=channels[0]),
|
|
),
|
|
(
|
|
DetailView(image, channel=channels[1]),
|
|
ChannelEditView(image, channel=channels[1]),
|
|
),
|
|
(
|
|
DetailView(image, channel=channels[2]),
|
|
ChannelEditView(image, channel=channels[2]),
|
|
),
|
|
]
|
|
)
|
|
|
|
while True:
|
|
for channel in channels:
|
|
config.set_channel(channel.channel, channel)
|
|
channel.update()
|
|
if channel.alarm:
|
|
alarm.trigger()
|
|
|
|
alarm.update(light.get_lux() < 4.0)
|
|
|
|
viewcontroller.update()
|
|
viewcontroller.render()
|
|
display.display(image.convert("RGB"))
|
|
|
|
config.set_general(
|
|
{
|
|
"alarm_enable": alarm.enabled,
|
|
"alarm_interval": alarm.interval,
|
|
}
|
|
)
|
|
|
|
config.save()
|
|
|
|
time.sleep(1.0 / FPS)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|