Prechádzať zdrojové kódy

dufam ze to bude fungovat

marek 1 mesiac pred
rodič
commit
13870499c8

+ 21 - 0
.env.example

@@ -0,0 +1,21 @@
+# ---
+
+# ble
+
+BLE_PERIPHERAL_NAME="meteostanica-exterier"
+
+# ---
+
+# webserver
+
+HOSTNAME="127.0.0.1"
+PORT="3000"
+
+# ---
+
+# post data
+
+BACKEND_URL="wss://meteostanica.com"
+BACKEND_WEBSOCKET_KEY=""
+
+DATA_POST_INTERVAL=30

+ 15 - 0
.gitignore

@@ -0,0 +1,15 @@
+# bun packages
+node_modules
+bun.lock
+
+# vs code
+.vscode
+
+# secrets
+.env
+
+# sqlite databases
+data/**
+
+# keep empty folders
+!**/.gitkeep

+ 18 - 1
README.md

@@ -1,3 +1,20 @@
 # interier
 
-kod pre vnutornu (hlavnu) jednotku meteostanice
+kód pre vnutornú (hlavnú) jednotku meteostanice
+
+## spustenie
+1. nainštalujte si [python](https://python.org)
+2. nainštalujte si [bun](https://bun.sh)
+3. nainštalujte knižnice `bun i`
+4. skopírujte súbor `.env.example` do `.env`
+5. upravte súbor `.env` s vašimi hodnotami
+
+## spustenie - getData (prijimanie dat z externej jednotky + interných senzorov)
+1. nainstalujte kniznice bleak a dotenv (priklad `apt install python3-{bleak,dotenv,bme280}`)
+2. spustite pomocou `python3 apps/ble.py`
+
+## spustenie - postData (posielanie dát na externý server)
+- spustite pomocou `bun apps/postData.js`
+
+## spustenie - webserver (webserver na lokálnej sieti)
+- spustite pomocou `bun apps/webserver.js`

+ 143 - 0
apps/getData.py

@@ -0,0 +1,143 @@
+import asyncio
+from bleak import BleakClient, BleakScanner
+from bleak.backends.characteristic import BleakGATTCharacteristic
+import struct
+import sqlite3
+from dotenv import load_dotenv
+import os
+
+from smbus2 import SMBus
+from bme280 import BME280
+
+# Initialise the BME280
+bus = SMBus(1)
+bme280 = BME280(i2c_dev=bus)
+
+load_dotenv()
+
+outdoorName = os.getenv("BLE_PERIPHERAL_NAME")
+outdoorConnected = 0
+
+TEMP_UUID = "2A6E"
+PRESSURE_UUID = "2A6D"
+HUMIDITY_UUID = "2A6F"
+ALTITUDE_UUID = "2AB3"
+
+indoorValues = {
+    "Temperature": 0,
+    "Pressure": 0,
+    "Humidity": 0,
+    "Altitude": 0,
+}
+
+outdoorValues = {
+    "Temperature": 0,
+    "Pressure": 0,
+    "Humidity": 0,
+    "Altitude": 0,
+}
+
+def notification_handler(characteristic: BleakGATTCharacteristic, data: bytearray):
+    global outdoorValues
+    outdoorValues[characteristic.description] = struct.unpack("<i", data)[0]
+
+async def ble_task():
+    global outdoorConnected
+
+    while True:
+        disconnect_event = asyncio.Event()
+
+        device = await BleakScanner.find_device_by_name(outdoorName)
+
+        if device is None:
+            outdoorConnected = 0
+
+            print("no device found, wait then scan again")
+            await asyncio.sleep(30)
+            continue
+
+        try:
+            async with BleakClient(device, disconnected_callback=lambda c: disconnect_event.set()) as client:
+                outdoorConnected = 1
+
+                await client.start_notify(TEMP_UUID, notification_handler)
+                await client.start_notify(PRESSURE_UUID, notification_handler)
+                await client.start_notify(HUMIDITY_UUID, notification_handler)
+                await client.start_notify(ALTITUDE_UUID, notification_handler)
+
+                await disconnect_event.wait()
+        except Exception:
+            outdoorConnected = 0
+            print("exception while connecting or getting data")
+
+async def sensor_task():
+    global indoorValues
+    
+    while True:
+        temperature, pressure, humidity = bme280.read_compensated_data()
+        altitude = bme280.altitude
+        
+        #print(_encode_value(pressure/100))
+
+        indoorValues["Temperature"] = temperature * 100
+        indoorValues["Pressure"] = pressure * 100
+        indoorValues["Humidity"] = humidity * 100
+        indoorValues["Altitude"] = altitude * 100
+        
+        await asyncio.sleep(1)
+
+conn = sqlite3.connect('./data/meteostanica.sqlite')
+cursor = conn.cursor()
+
+cursor.execute(
+    """CREATE TABLE IF NOT EXISTS data (
+        timestamp datetime default current_timestamp primary key,
+        
+        indoorTemp integer not null,
+        indoorPressure integer not null,
+        indoorHumidity integer not null,
+        indoorAltitude integer not null,
+
+        outdoorConnected integer not null,
+        outdoorTemp integer not null,
+        outdoorPressure integer not null,
+        outdoorHumidity integer not null,
+        outdoorAltitude integer not null
+    );"""
+)
+
+conn.commit()
+
+async def write_task():
+    while True:
+        cursor.execute(f"""INSERT INTO data (
+            'indoorTemp', 'indoorPressure', 'indoorHumidity', 'indoorAltitude', 'outdoorConnected', 'outdoorTemp', 'outdoorPressure', 'outdoorHumidity', 'outdoorAltitude'
+        ) VALUES (
+            ?, ?, ?, ?, ?, ?, ?, ?, ?
+        )""",
+        (
+            indoorValues["Temperature"], 
+            indoorValues["Pressure"], 
+            indoorValues["Humidity"], 
+            indoorValues["Altitude"],
+            outdoorConnected,
+            outdoorValues["Temperature"], 
+            outdoorValues["Pressure"], 
+            outdoorValues["Humidity"], 
+            outdoorValues["Altitude"]
+        ))
+
+        conn.commit()
+
+        await asyncio.sleep(10)
+
+async def main():
+    try:
+        t1 = asyncio.create_task(ble_task())
+        t2 = asyncio.create_task(sensor_task())
+        t3 = asyncio.create_task(write_task())
+        await asyncio.gather(t1, t2, t3)
+    except asyncio.exceptions.CancelledError:
+        pass
+    
+asyncio.run(main())

+ 10 - 0
apps/postData.js

@@ -0,0 +1,10 @@
+import Meteostanica from "../utils/meteostanica";
+
+const socket = new WebSocket(`${process.env.BACKEND_URL}/ws/sendData/${process.env.BACKEND_WEBSOCKET_KEY}`);
+
+setInterval(() => {
+    const data = Meteostanica.getData()[0]
+    delete data.timestamp
+
+    socket.send(JSON.stringify(data))
+}, process.env.DATA_POST_INTERVAL * 1000)

+ 15 - 0
apps/webserver.js

@@ -0,0 +1,15 @@
+import { Elysia } from 'elysia'
+
+import routes from '../routes'
+
+new Elysia()
+    .use(routes)
+    .get('/assets/*', async ({ params, status }) => {
+        const file = Bun.file(`./assets/${params["*"]}`)
+        if (!(await file.exists())) return status(404, "not found")
+
+        return file;
+    })
+    .listen({ hostname: process.env.HOSTNAME, port: process.env.PORT })
+
+console.log(`running on ${process.env.HOSTNAME}:${process.env.PORT}`)

+ 85 - 0
assets/css/style.css

@@ -0,0 +1,85 @@
+body {
+    padding-inline: 10%;
+}
+
+nav {
+    display: flex;
+    gap: 1rem 3rem;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: space-between;
+    margin-inline: 5%;
+    padding: 0.5rem 3rem;
+}
+
+nav h2 {
+    margin: 0 auto;
+}
+
+nav ul {
+    list-style: none;
+    display: flex;
+    gap: 1rem;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: center;
+    padding: 0;
+    margin: 0 auto;
+}
+
+nav li {
+    margin-inline: auto;
+}
+
+/* nav a {
+    color: #eee;
+    text-decoration: none;
+    font-size: 1.2rem;
+} */
+
+header {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: space-between;
+    padding-block: 2rem;
+    gap: 2rem;
+}
+
+img {
+    object-fit: cover;
+    width: 100%;
+}
+
+.stats {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 2rem;
+}
+
+.stats > div {
+    padding: 0.5rem 2rem;
+    background-color: lightgray;
+}
+
+div:has(> table) {
+  overflow-x: auto;
+}
+
+table {
+  border-collapse: collapse;
+  width: 100%;
+}
+
+td, th {
+  border: 1px solid #dddddd;
+  text-align: left;
+  padding: 8px;
+}
+
+tr:nth-child(even) {
+  background-color: #dddddd;
+}
+
+span.error {
+    color: red;
+}

+ 0 - 0
data/.gitkeep


+ 11 - 0
package.json

@@ -0,0 +1,11 @@
+{
+  "name": "meteostanica-interier",
+  "type": "module",
+  "devDependencies": {
+    "@types/bun": "^1.3.9"
+  },
+  "dependencies": {
+    "elysia": "^1.4.25",
+    "eta": "^4.5.1"
+  }
+}

+ 14 - 0
routes/include/main.js

@@ -0,0 +1,14 @@
+import { Elysia } from 'elysia'
+
+import { Eta } from "eta"
+const eta = new Eta({ views: "./templates" })
+
+import Meteostanica from '../../utils/meteostanica'
+
+export default (langName) => new Elysia()
+  .get('/', ({ set }) => {
+    const data = Meteostanica.getData()
+
+    set.headers['content-type'] = 'text/html; charset=utf8'
+    return eta.render(`${langName}/index`, { data })
+  })

+ 8 - 0
routes/index.js

@@ -0,0 +1,8 @@
+import { Elysia } from 'elysia'
+
+import enRoutes from './lang/en'
+import skRoutes from './lang/sk'
+
+export default new Elysia()
+    .use(skRoutes)
+    .use(enRoutes)

+ 6 - 0
routes/lang/en.js

@@ -0,0 +1,6 @@
+import { Elysia } from 'elysia'
+
+import mainRoutes from '../include/main'
+
+export default new Elysia({ prefix: "/en" })
+  .use(mainRoutes("en"))

+ 6 - 0
routes/lang/sk.js

@@ -0,0 +1,6 @@
+import { Elysia } from 'elysia'
+
+import mainRoutes from '../include/main'
+
+export default new Elysia()
+  .use(mainRoutes("sk"))

+ 46 - 0
templates/en/index.eta

@@ -0,0 +1,46 @@
+<% layout("/en/layout") %>
+
+<%~ include("/en/partials/navbar") %>
+
+<% if (it.data?.[0]) { %>
+<div class="stats">
+    <div class="indoor">
+        <h3>indoor</h3>
+        <p>temp: <%= it.data?.[0].indoorTemp / 100 %> °C</p>
+        <p>pressure: <%= it.data?.[0].indoorPressure / 100 %> hPa</p>
+        <p>humidity: <%= it.data?.[0].indoorHumidity / 100 %>%</p>
+        <p>altitude: <%= it.data?.[0].indoorAltitude / 100 %>m</p>
+    </div>
+    <div class="outdoor">
+        <h3>outdoor</h3>
+        <p>connected: <%= it.data?.[0].outdoorConnected %></p>
+        <p>temp: <%= it.data?.[0].outdoorTemp / 100 %> °C</p>
+        <p>pressure: <%= it.data?.[0].outdoorPressure / 100 %> hPa</p>
+        <p>humidity: <%= it.data?.[0].outdoorHumidity / 100 %>%</p>
+        <p>altitude: <%= it.data?.[0].outdoorAltitude / 100 %>m</p>
+    </div>
+</div>
+<% } %>
+
+<h3>data</h3>
+
+<% if (it.data?.length) { %>
+    <div>
+        <table>
+            <tr>
+                <th>time</th>
+                <th>indoor</th>
+                <th>outdoor</th>
+            </tr>
+            <% for (const item of it.data) { %>
+                <tr>
+                    <td><%= item.timestamp %></td>
+                    <td><strong>temp:</strong> <%= item.indoorTemp / 100 %> °C, <strong>pressure:</strong> <%= item.indoorPressure / 100 %> hPa, <strong>humidity:</strong> <%= item.indoorHumidity / 100 %>%, <strong>altitude:</strong> <%= item.indoorAltitude / 100 %>m</td>
+                    <td><strong>connected:</strong> <%= item.outdoorConnected %>, <strong>temp:</strong> <%= item.outdoorTemp / 100 %> °C, <strong>pressure:</strong> <%= item.outdoorPressure  / 100%>hPa, <strong>humidity:</strong> <%= item.outdoorHumidity / 100 %>%, <strong>altitude:</strong> <%= item.outdoorAltitude / 100 %>m</td>
+                </tr>
+            <% } %>
+        </table>
+    </div>
+<% } else { %>
+    <p>no data yet. :(</p>
+<% } %>

+ 12 - 0
templates/en/layout.eta

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title><%= it.title ? `${it.title} — meteostanica` : "meteostanica" %></title>
+    <link rel="stylesheet" href="/assets/css/style.css">
+</head>
+<body>
+    <%~ it.body %>
+</body>
+</html>

+ 7 - 0
templates/en/partials/navbar.eta

@@ -0,0 +1,7 @@
+<nav>
+    <h2>meteostanica</h2>
+
+    <ul>
+        <li><a href="/">⛰️⛰️⛰️🇸🇰🇸🇰🇸🇰</a></li>
+    </ul>
+</nav>

+ 46 - 0
templates/sk/index.eta

@@ -0,0 +1,46 @@
+<% layout("/sk/layout") %>
+
+<%~ include("/sk/partials/navbar") %>
+
+<% if (it.data?.[0]) { %>
+    <div class="stats">
+        <div class="indoor">
+            <h3>vnútorné</h3>
+            <p>teplota: <%= it.data?.[0].indoorTemp / 100 %> °C</p>
+            <p>tlak: <%= it.data?.[0].indoorPressure / 100 %> hPa</p>
+            <p>vlhkosť: <%= it.data?.[0].indoorHumidity / 100 %>%</p>
+            <p>nadmorská výška: <%= it.data?.[0].indoorAltitude / 100 %>m</p>
+        </div>
+        <div class="outdoor">
+            <h3>vonkajšie</h3>
+            <p>pripojené: <%= it.data?.[0].outdoorConnected %></p>
+            <p>teplota: <%= it.data?.[0].outdoorTemp / 100 %> °C</p>
+            <p>tlak: <%= it.data?.[0].outdoorPressure / 100 %> hPa</p>
+            <p>vlhkosť: <%= it.data?.[0].outdoorHumidity / 100 %>%</p>
+            <p>nadmorská výška: <%= it.data?.[0].outdoorAltitude / 100 %>m</p>
+        </div>
+    </div>
+<% } %>
+
+<h3>dáta</h3>
+
+<% if (it.data?.length) { %>
+    <div>
+        <table>
+            <tr>
+                <th>čas</th>
+                <th>vnútorné</th>
+                <th>vonkajšie</th>
+            </tr>
+            <% for (const item of it.data) { %>
+                <tr>
+                    <td><%= item.timestamp %></td>
+                    <td><strong>teplota:</strong> <%= item.indoorTemp / 100 %> °C, <strong>tlak:</strong> <%= item.indoorPressure / 100 %> hPa, <strong>vlhkosť:</strong> <%= item.indoorHumidity / 100 %>%, <strong>nadmorská výška:</strong> <%= item.indoorAltitude / 100 %>m</td>
+                    <td><strong>pripojené:</strong> <%= item.outdoorConnected %>, <strong>teplota:</strong> <%= item.outdoorTemp / 100 %> °C, <strong>tlak:</strong> <%= item.outdoorPressure  / 100%>hPa, <strong>vlhkosť:</strong> <%= item.outdoorHumidity / 100 %>%, <strong>nadmorská výška:</strong> <%= item.outdoorAltitude / 100 %>m</td>
+                </tr>
+            <% } %>
+        </table>
+    </div>
+<% } else { %>
+    <p>zatiaľ žiadne dáta. :(</p>
+<% } %>

+ 12 - 0
templates/sk/layout.eta

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="sk">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title><%= it.title ? `${it.title} — meteostanica` : "meteostanica" %></title>
+    <link rel="stylesheet" href="/assets/css/style.css">
+</head>
+<body>
+    <%~ it.body %>
+</body>
+</html>

+ 7 - 0
templates/sk/partials/navbar.eta

@@ -0,0 +1,7 @@
+<nav>
+    <h2>meteostanica</h2>
+
+    <ul>
+        <li><a href="/en">🦅🦅🦅🇺🇸🇺🇸🇺🇸</a></li>
+    </ul>
+</nav>

+ 32 - 0
utils/meteostanica.js

@@ -0,0 +1,32 @@
+import { Database } from "bun:sqlite";
+
+const meteostanicaDB = new Database("./data/meteostanica.sqlite");
+
+meteostanicaDB.run(`create table if not exists data (
+    timestamp datetime default current_timestamp primary key,
+
+    indoorTemp text not null,
+    indoorPressure text not null,
+    indoorHumidity text not null,
+    indoorAltitude text not null,
+
+    outdoorConnected integer not null,
+    outdoorTemp text not null,
+    outdoorPressure text not null,
+    outdoorHumidity text not null,
+    outdoorAltitude text not null
+);`)
+
+export default class Meteostanica {
+    static getData() {
+        const statement = meteostanicaDB.prepare(`
+            SELECT *
+            FROM data
+            ORDER BY timestamp DESC;
+        `)
+
+        const result = statement.all();
+
+        return result
+    }
+}