Skip to content
This repository was archived by the owner on Nov 10, 2023. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions lib/device.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import socket
from typing import Union
from datetime import datetime
from time import sleep, mktime
from requests import get, post, patch
Expand All @@ -17,10 +19,65 @@ def __init__(self, tick_rate: int) -> None:

self.id = POCKETBASE_DEVICE_ID
self.pb = PocketBase(get, post, patch, mktime)
self.socket = self.server_init()

def get_current_time(self) -> int:
return int(datetime.now(utc).timestamp())

def handle_system_error(self, error: str) -> None:
print("-> Error:", error)

def server_init(self):
addr = socket.getaddrinfo('0.0.0.0', 9999)[0][-1]
s = socket.socket()
s.bind(addr)
s.listen(1)
print('-> Listening on', addr)
return s

def server_listen_pot_irrigation(self) -> Union[None, str]:
client, _ = self.socket.accept()
request = client.recv(1024).decode('utf-8')
headers = self.server_parse_request(request)

if 'OPTIONS' in request:
# Handling preflight request
response_headers = [
"HTTP/1.1 204 No Content",
"Access-Control-Allow-Origin: *",
"Access-Control-Allow-Methods: POST, OPTIONS",
"Access-Control-Allow-Headers: Authorization, Content-Type, Pot-Id",
"Access-Control-Max-Age: 3600" # Cache preflight response for 1 hour
]
response = "\r\n".join(response_headers) + "\r\n\r\n"
client.send(response.encode('utf-8'))

elif 'POST' in request:
if 'Authorization' in headers and 'Pot-Id' in headers and self.pb.is_valid_jwt(headers['Authorization']):
response_headers = [
"HTTP/1.1 200 OK",
"Content-Type: application/json",
"Access-Control-Allow-Origin: *" # Required for CORS
]
response = "\r\n".join(response_headers)
client.send(response.encode('utf-8'))

return headers['Pot-Id']
else:
client.send('HTTP/1.1 400 Bad Request\r\n\r\n'.encode('utf-8'))
else:
client.send('HTTP/1.1 404 Not Found\r\n\r\n'.encode('utf-8'))

client.close()

def server_parse_request(self, request):
headers = {}
lines = request.split('\r\n')
for line in lines[1:]:
if ": " in line:
key, value = line.split(": ", 1)
headers[key] = value
return headers

def server_stop(self):
self.socket.close()
10 changes: 10 additions & 0 deletions lib/pocketbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,13 @@ def get_jwt(self) -> str:
raise RuntimeError("-> PocketBase: Invalid credentials", response.status_code)

return response.json()["token"]

def is_valid_jwt(self, external_jwt: str) -> bool:
url = f"{POCKETBASE_SERVER_URL}/api/settings"
headers = {
"Content-Type": "application/json",
"Authorization": external_jwt # e.g. `Bearer aaaa.bbbb.cccc`
}
response = self.get(url, headers=headers)

return response.status_code == 200
4 changes: 4 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ def __init__(self) -> None:
def loop(self) -> None:
try:
while True:
# Check manual irrigation requests
pot_id_to_irrigate = self.device.server_listen_pot_irrigation()

for pot in self.pots:
if pot.id == pot_id_to_irrigate: pot.irrigate()
pot.update()

# Only run the loop once in tests
Expand Down
29 changes: 29 additions & 0 deletions pocketbase/pb_migrations/1691941204_updated_devices.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("8gfvyih1w3lb3c7")

// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "etipjjde",
"name": "ip",
"type": "text",
"required": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}))

return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("8gfvyih1w3lb3c7")

// remove
collection.schema.removeField("etipjjde")

return dao.saveCollection(collection)
})
72 changes: 72 additions & 0 deletions scripts/http_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import socket
from requests import get

def parse_request(request):
headers = {}
lines = request.split('\r\n')
for line in lines[1:]:
if ": " in line:
key, value = line.split(": ", 1)
headers[key] = value
return headers

def main():
addr = socket.getaddrinfo('0.0.0.0', 9999)[0][-1]

s = socket.socket()
s.bind(addr)
s.listen(1)

print('listening on', addr)

while True:
cl, addr = s.accept()
print('client connected from', addr)
request = cl.recv(1024).decode('utf-8')

headers = parse_request(request)

if 'OPTIONS' in request:
# Handling preflight request
response_headers = [
"HTTP/1.1 204 No Content",
"Access-Control-Allow-Origin: *",
"Access-Control-Allow-Methods: POST, OPTIONS",
"Access-Control-Allow-Headers: Authorization, Content-Type, Pot-Id",
"Access-Control-Max-Age: 3600" # Cache preflight response for 1 hour
]
response = "\r\n".join(response_headers) + "\r\n\r\n"
cl.send(response.encode('utf-8'))

elif 'POST' in request:
if 'Authorization' in headers and 'Pot-Id' in headers and is_valid_jwt(headers['Authorization']):
response_headers = [
f"HTTP/1.1 200 OK",
"Content-Type: application/json",
"Access-Control-Allow-Origin: *" # Required for CORS
]
response = "\r\n".join(response_headers)

cl.send(response.encode('utf-8'))
else:
cl.send('HTTP/1.1 400 Bad Request\r\n\r\n'.encode('utf-8'))
else:
cl.send('HTTP/1.1 404 Not Found\r\n\r\n'.encode('utf-8'))

cl.close()


def is_valid_jwt(jwt: str) -> bool:
headers = {
"Content-Type": "application/json",
"Authorization": jwt # e.g. `Bearer aaaa.bbbb.cccc`
}

url = "http://127.0.0.1:8090/api/settings"
response = get(url, headers=headers)

return response.status_code == 200


if __name__ == "__main__":
main()
4 changes: 3 additions & 1 deletion test/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@ def test_no_pots(self, mock_handle_system_error):
mock_handle_system_error.assert_called_once_with("No pots to irrigate")

@patch.object(AutoGarden, "setup_pots")
def test_irrigation_loop(self, mock_setup_pots):
@patch.object(Device, "server_listen_pot_irrigation", return_value=None)
def test_irrigation_loop(self, mock_device_server_listen, mock_setup_pots):
mock_pot = MagicMock()
mock_setup_pots.return_value = [mock_pot, mock_pot]
self.auto_garden = AutoGarden()
self.assertEqual(mock_pot.update.call_count, 2)
mock_device_server_listen.assert_called_once()


if __name__ == '__main__':
Expand Down
4 changes: 3 additions & 1 deletion test/test_pot.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ def setUpClass(cls):
PUMP_FREQUENCY_IN_S,
PUMP_RELAY_PIN
)
cls.device = Device(2)

@classmethod
def tearDownClass(cls):
testboi.pocketbase_stop()
cls.device.server_stop()

def test_init(self):
pot = self.new_pot()
Expand Down Expand Up @@ -272,7 +274,7 @@ def test_irrigate(self):

def new_pot(self) -> Pot:
return Pot(
Device(2),
self.device,
self.pot_record["id"],
self.pot_record["name"],
self.pot_record["moisture_low"],
Expand Down
19 changes: 18 additions & 1 deletion web/body_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default class BodyController extends Controller {
"potIrrigationPumps",
"potIrrigationUpdated",
"potLineChart",
"potId",
"notice",
"serverUrl",
"serverUsername",
Expand Down Expand Up @@ -123,6 +124,8 @@ export default class BodyController extends Controller {
this.potNameTargets[index].textContent = lastestMoisture.expand.pot.name; // prettier-ignore
this.potMoistureLevelTargets[index].textContent = `${lastestMoisture.level}%`; // prettier-ignore
this.potMoistureUpdatedTargets[index].setAttribute("datetime", new Date(lastestMoisture.updated).toISOString())
this.potIdTargets[index].setAttribute("data-pot-id", lastestMoisture.expand.pot.id);
this.potIdTargets[index].setAttribute("data-device-ip", pot.expand.device.ip);

const lastestIrrigation = irrigations.items[irrigations.items.length - 1]; // prettier-ignore
if (lastestIrrigation) {
Expand Down Expand Up @@ -231,7 +234,7 @@ export default class BodyController extends Controller {
}

async getPots() {
return await this.pb.collection("pots").getList(1, 500);
return await this.pb.collection("pots").getList(1, 500, { expand: "device" });
}

async setPotTemplate() {
Expand Down Expand Up @@ -277,6 +280,20 @@ export default class BodyController extends Controller {
});
}

irrigate(e) {
const potId = e.currentTarget.getAttribute("data-pot-id")
const url = `http://${e.currentTarget.getAttribute("data-device-ip")}:9999`

fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.pb.authStore.baseToken}`,
"Pot-Id": potId
},
})
}

async disconnect() {
await this.pb.collection("moistures").unsubscribe();
await this.pb.collection("irrigations").unsubscribe();
Expand Down
2 changes: 2 additions & 0 deletions web/pot.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@
</dl>
<hr>
<canvas data-body-target="potLineChart" height="320"></canvas>
<hr>
<button type="button" data-body-target="potId" data-action="click->body#irrigate">Irrigate</button>
</fieldset>