Compare commits

..

No commits in common. "c2afc3654c201b38501776603f64f42b00d656d6" and "81001d3290f56e0a8370b94df5235a1d68db2657" have entirely different histories.

12 changed files with 0 additions and 479 deletions

View file

@ -1 +0,0 @@
3.13

View file

102
a5/db.py
View file

@ -1,102 +0,0 @@
import sqlite3
from contextlib import closing
from typing import List
class Stats:
"""Holds statistics of measurements"""
tvoc_max: sqlite3.Row | None
tvoc_min: sqlite3.Row | None
co2_min: sqlite3.Row | None
co2_max: sqlite3.Row | None
count: int
class DB:
"""Class for handling database interactions"""
# sql
CREATE_SQL = """
CREATE TABLE IF NOT EXISTS measurements
(
timestamp real not null primary key,
tvoc real not null,
co2 real not null
);
"""
def __init__(self, db_name: str = "greetings.db"):
"""Class init"""
self.db_name = db_name
self._conn = sqlite3.connect(
self.db_name, detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES
)
self._conn.row_factory = sqlite3.Row
cursor = self._conn.cursor()
cursor.execute(self.CREATE_SQL)
self._conn.commit()
def store(self, timestamp: float, tvoc: float, co2: float) -> int | None:
"""Adds a measurement"""
with closing(self._conn.cursor()) as cursor:
cursor.execute(
# sql
"INSERT INTO measurements (timestamp, tvoc, co2) VALUES (?, ?, ?)",
(timestamp, tvoc, co2),
)
self._conn.commit()
return cursor.lastrowid
def get_stats(self) -> Stats:
"""Returns measurement statistics"""
stats = Stats()
with closing(self._conn.cursor()) as cursor:
# this could probably be made into a single query, but the database being sqlite results in much lower latency with multiple selects
# returned object could also be better typed
cursor.execute(
# sql
"SELECT tvoc, timestamp FROM measurements ORDER BY tvoc DESC LIMIT 1"
)
stats.tvoc_max = cursor.fetchone()
cursor.execute(
# sql
"SELECT tvoc, timestamp FROM measurements ORDER BY tvoc ASC LIMIT 1"
)
stats.tvoc_min = cursor.fetchone()
cursor.execute(
# sql
"SELECT co2, timestamp FROM measurements ORDER BY co2 DESC LIMIT 1"
)
stats.co2_max = cursor.fetchone()
cursor.execute(
# sql
"SELECT co2, timestamp FROM measurements ORDER BY co2 ASC LIMIT 1"
)
stats.co2_min = cursor.fetchone()
stats.count = self.count()
return stats
def get_page(self, page: int, page_size: int = 20) -> List[sqlite3.Row]:
"""Gets a paged list of all measurements. Page is 0 indexed."""
with closing(self._conn.cursor()) as cursor:
cursor.execute(
# sql
"SELECT timestamp, tvoc, co2 FROM measurements ORDER BY timestamp DESC LIMIT ?, ?",
(page_size, page * page_size),
)
return cursor.fetchall()
def count(self) -> int:
"""Counts number of measurements"""
with closing(self._conn.cursor()) as cursor:
cursor.execute(
# sql
"SELECT COUNT(*) as count FROM MEASUREMENTS"
)
return cursor.fetchone()["count"]
def close(self) -> None:
"""Closes the DB connection"""
self._conn.close()

View file

@ -1,81 +0,0 @@
from time import time
from flask import Flask, g, render_template, request
from flask.typing import ResponseReturnValue
from flask_htmx import HTMX
from db import DB
app = Flask(__name__)
htmx = HTMX(app)
@app.route("/api/report", methods=["POST"])
def report() -> ResponseReturnValue:
"""Adds a measurement"""
# timestamp = request.json["timestamp"]
tvoc = request.json["tvoc"]
co2 = request.json["co2"]
try:
# timestamp = int(timestamp)
tvoc = float(tvoc)
co2 = float(co2)
except ValueError:
return "Invalid data", 400
db = get_db()
timestamp = time()
db.store(timestamp, tvoc, co2)
return "ok", 200
@app.route("/stats", methods=["GET"])
def stats() -> ResponseReturnValue:
"""Gets overall statistics as well as few recent measurements"""
db = get_db()
stats = db.get_stats()
recents = db.get_page(0, 10)
template = "page/index.html.j2"
if htmx:
template = "block/index.html.j2"
return render_template(template, stats=stats, list=recents)
@app.route("/measurements", methods=["GET"])
def measurements() -> ResponseReturnValue:
"""Lists measurements"""
db = get_db()
page_size = 20
page = request.args["page"]
try:
page = int(page)
if page < 0:
raise ValueError()
except ValueError:
return "Invalid page", 400
data = db.get_page(page, page_size)
count = db.count()
has_next_page = count > page * page_size
template = "page/measurements.html.j2"
if htmx:
template = "block/measurements.html.j2"
return render_template(template, has_next_page=has_next_page, page=page, list=data)
def get_db() -> DB:
"""gets database connection"""
db_instance = getattr(g, "_database", None)
if db_instance is None:
db_instance = g._database = DB()
return db_instance
@app.teardown_appcontext
def close_connection(_exception):
"""disconnects database on connection close (if opened)"""
db_instance = getattr(g, "_database", None)
if db_instance is not None:
db_instance.close()
if __name__ == "__main__":
app.run(debug=True)

View file

@ -1,7 +0,0 @@
[project]
name = "a5"
version = "0.1.0"
description = "Sensor driven website"
readme = "README.md"
requires-python = ">=3.13"
dependencies = []

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" data-astro-cid-6ygtcg62="true" data-icon="solar:arrow-right-linear"> <symbol id="ai:solar:arrow-right-linear" viewBox="0 0 24 24"><path fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 12h16m0 0l-6-6m6 6l-6 6"></path></symbol><use href="#ai:solar:arrow-right-linear"></use> </svg>

Before

Width:  |  Height:  |  Size: 399 B

View file

@ -1,164 +0,0 @@
:root {
--font-mono: "Fira Code VF", "Fira Code", monospace;
--font-sans: "Fira Sans", sans-serif;
--primary-dark: #242038;
--primary: #9067c6;
--bg-dark: #100d11;
--bg: #110f1a;
--bg-success: #7ceec8;
--gray-700: oklch(37.3% 0.034 259.733);
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
background: var(--bg);
font-family: var(--font-mono);
}
code {
font-variant-ligatures: none;
}
* {
user-select: none;
box-sizing: border-box;
color: white;
text-decoration: none;
padding: 0;
margin: 0;
border: none;
background: none;
}
.bg-gradient {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, var(--primary), var(--bg));
opacity: 0.06;
pointer-events: none;
}
nav {
z-index: 100;
/*background: var(--bg);*/
top: 0;
left: 50%;
transform: translateX(-50%);
position: fixed;
flex: 1;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 70rem;
padding: 0.5rem;
margin: auto;
text-transform: uppercase;
display: flex;
}
.nav-spacer {
height: 3rem;
}
nav .links {
display: flex;
gap: 0.5rem;
}
.font-bold {
font-weight: bold;
}
.text-gray-500 {
color: #999;
}
.btn {
isolation: isolate;
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
}
.btn-border {
border: 1px solid var(--gray-700);
}
.btn::before {
z-index: -1;
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 0;
background: var(--primary);
transition: all 0.3s ease;
}
.btn:hover::before {
width: 100%;
height: 100%;
}
main {
max-width: 70rem;
margin: auto;
padding: 1rem;
}
.rounded-full {
border-radius: 999px;
}
.card {
display: flex;
gap: 1rem;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
background: var(--bg-dark);
}
.card > img {
height: 5rem;
width: 5rem;
}
.card .contents {
flex: 1;
flex-direction: column;
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.btn-link {
border: 1px solid var(--gray-700);
}
.btn-link::after {
content: "";
width: 1rem;
height: 1rem;
background-image: url("/static/icon-right.svg");
}
.btn-link-out {
border: 1px solid var(--gray-700);
}
.btn-link-out::after {
content: "";
width: 1rem;
height: 1rem;
background-image: url("/static/icon-link.svg");
}
.text-error {
color: red;
}

View file

@ -1,49 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0"
>
<meta name="description"
content="{% block description %}Sensor driven website{% endblock %}"
>
<title>Sensor - {% block title %}{% endblock %}</title>
<link rel="preconnect"
href="https://fonts.googleapis.com"
>
<link rel="preconnect"
href="https://fonts.gstatic.com"
crossorigin
>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
rel="stylesheet"
>
<link href="/static/styles.css"
rel="stylesheet"
>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
</head>
<body>
<div class="bg-gradient"></div>
<nav>
<a href="/" class="font-bold">
Sensor
<span class="text-gray-500">&gt;</span>
{{ self.title() }}
</a>
<div class="links">
<a href="/" class="btn">/</a>
<a href="/measurements" class="btn">/measurements</a>
</div>
</nav>
<div class="nav-spacer"></div>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View file

@ -1,31 +0,0 @@
<div hx-get="/" hx-trigger="every 2s" hx-target="main"></div>
<h1>Overview</h1>
<article class="card">
<div class="contents">
Count {{ stats.count }}
</div>
</article>
<h2>Recent measurements</h2>
{% for row in list %}
<article class="card">
<div class="contents">
<header>@ {{row['timestamp']}}</header>
CO2: {{row['co2']}}
TVOC: {{row['tvoc']}}
</div>
</article>
{% else %}
<article class="card">
<div class="contents">
<header>No measurements yet.</header>
</div>
</article>
{% endfor %}
<div>
<a href="/measurements" class="btn btn-link">View measurements</a>
</div>

View file

@ -1,31 +0,0 @@
<div hx-get="/measurements" hx-trigger="every 2s" hx-target="main"></div>
<h1>Measurements</h1>
<p>Page {{page}}</p>
{% for row in list %}
<article class="card">
<div class="contents">
<header>@ {{row['timestamp']}}</header>
CO2: {{row['co2']}}
TVOC: {{row['tvoc']}}
</div>
</article>
{% else %}
<article class="card">
<div class="contents">
<header>No measurements yet.</header>
</div>
</article>
{% endfor %}
<div>
{% if has_next_page %}
<a href="/measurements?page={{page+1}}" class="btn btn-link">Previous page</a>
{% endif %}
{% if page > 0 %}
<a href="/measurements?page={{page-1}}" class="btn btn-link">Next page</a>
{% endif %}
<a href="/" class="btn btn-link">View statistics</a>
</div>

View file

@ -1,6 +0,0 @@
{% extends "../base.html.j2" %}
{% block title %}Overview{% endblock %}
{% block description %}Overview of a CO2 and TVOC sensor data.{% endblock %}
{% block content %}
{% include "../block/index.html.j2" %}
{% endblock %}

View file

@ -1,6 +0,0 @@
{% extends "../base.html.j2" %}
{% block title %}Measurements{% endblock %}
{% block description %}List of measurements{% endblock %}
{% block content %}
{% include "../block/measurements.html.j2" %}
{% endblock %}