u
This commit is contained in:
153
CO2_SENSOR_INTEGRATION.md
Normal file
153
CO2_SENSOR_INTEGRATION.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# CO2 Sensor Integration Guide
|
||||||
|
|
||||||
|
This document explains how to write CO2 sensor data directly to the `ac_data.db` SQLite database so it appears under the "Wall" device on the dashboard.
|
||||||
|
|
||||||
|
## Database Location
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/seb/src/actest/ac_data.db
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE readings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
dev_id TEXT,
|
||||||
|
dev_name TEXT,
|
||||||
|
port INTEGER,
|
||||||
|
port_name TEXT,
|
||||||
|
temp_c REAL,
|
||||||
|
humidity REAL,
|
||||||
|
vpd REAL,
|
||||||
|
fan_speed INTEGER,
|
||||||
|
on_speed INTEGER,
|
||||||
|
off_speed INTEGER
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inserting CO2 Data
|
||||||
|
|
||||||
|
Add CO2 readings as a **new port** on the "Wall" device. Use **port 2** with `port_name = 'CO2'`.
|
||||||
|
|
||||||
|
### SQL Insert Statement
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO readings (dev_name, port, port_name, temp_c, humidity, vpd, fan_speed)
|
||||||
|
VALUES ('Wall', 2, 'CO2', NULL, NULL, NULL, <CO2_VALUE_PPM>);
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: The `fan_speed` column stores the CO2 ppm value. This reuses the existing integer column for sensor readings.
|
||||||
|
|
||||||
|
### Example: Insert 850 ppm
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO readings (dev_name, port, port_name, temp_c, humidity, vpd, fan_speed)
|
||||||
|
VALUES ('Wall', 2, 'CO2', NULL, NULL, NULL, 850);
|
||||||
|
```
|
||||||
|
|
||||||
|
### From Command Line (bash)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 /home/seb/src/actest/ac_data.db \
|
||||||
|
"INSERT INTO readings (dev_name, port, port_name, fan_speed) VALUES ('Wall', 2, 'CO2', 850);"
|
||||||
|
```
|
||||||
|
|
||||||
|
### From Node.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
const db = new Database('/home/seb/src/actest/ac_data.db');
|
||||||
|
|
||||||
|
function insertCO2Reading(ppm) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO readings (dev_name, port, port_name, fan_speed)
|
||||||
|
VALUES ('Wall', 2, 'CO2', ?)
|
||||||
|
`);
|
||||||
|
stmt.run(ppm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example: Insert reading
|
||||||
|
insertCO2Reading(850);
|
||||||
|
```
|
||||||
|
|
||||||
|
### From Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
DB_PATH = '/home/seb/src/actest/ac_data.db'
|
||||||
|
|
||||||
|
def insert_co2_reading(ppm):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO readings (dev_name, port, port_name, fan_speed)
|
||||||
|
VALUES ('Wall', 2, 'CO2', ?)
|
||||||
|
''', (ppm,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Example: Insert reading
|
||||||
|
insert_co2_reading(850)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dashboard Display
|
||||||
|
|
||||||
|
Once data is inserted, the dashboard will automatically:
|
||||||
|
|
||||||
|
1. Detect "Wall" device now has **2 ports**: Fan (port 1) and CO2 (port 2)
|
||||||
|
2. Display a new chart card labeled "CO2" under the Wall controller
|
||||||
|
|
||||||
|
### Current Limitation
|
||||||
|
|
||||||
|
The `LevelChart` component currently:
|
||||||
|
- Uses `fan_speed` for the Y-axis data ✅ (works for CO2)
|
||||||
|
- Labels chart as "Fan Speed" for non-light ports ⚠️
|
||||||
|
- Has Y-axis scale 0-10 ⚠️ (too small for CO2 ppm)
|
||||||
|
|
||||||
|
**The chart will display CO2 data, but the label and scale won't be ideal.**
|
||||||
|
|
||||||
|
## Recommended Dashboard Enhancements
|
||||||
|
|
||||||
|
To properly display CO2 data, modify `src/client/LevelChart.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Detect CO2 sensor type
|
||||||
|
const isCO2 = portName?.toLowerCase().includes('co2');
|
||||||
|
|
||||||
|
// Set appropriate label
|
||||||
|
const levelLabel = isLight ? 'Brightness' : (isCO2 ? 'CO2 (ppm)' : 'Fan Speed');
|
||||||
|
|
||||||
|
// Set appropriate Y-axis scale
|
||||||
|
const yScale = isCO2
|
||||||
|
? { suggestedMin: 400, suggestedMax: 2000 }
|
||||||
|
: { suggestedMin: 0, suggestedMax: 10, ticks: { stepSize: 1 } };
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
After inserting test data, verify it appears:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if data was inserted
|
||||||
|
sqlite3 /home/seb/src/actest/ac_data.db \
|
||||||
|
"SELECT * FROM readings WHERE port_name = 'CO2' ORDER BY timestamp DESC LIMIT 5;"
|
||||||
|
|
||||||
|
# View all ports for Wall device
|
||||||
|
sqlite3 /home/seb/src/actest/ac_data.db \
|
||||||
|
"SELECT DISTINCT dev_name, port, port_name FROM readings WHERE dev_name = 'Wall';"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cron Job Example
|
||||||
|
|
||||||
|
To log CO2 every minute from another script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# crontab -e
|
||||||
|
* * * * * /path/to/your/co2_logger.sh >> /var/log/co2_logger.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `co2_logger.sh` reads your sensor and inserts to the database.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# AC Infinity Dashboard & Logger
|
# Dashboard & Logger
|
||||||
|
|
||||||
This project logs data from AC Infinity controllers (via the Cloud API) and visualizes it on a local web dashboard.
|
This project logs data from AC Infinity controllers (via the Cloud API) and visualizes it on a local web dashboard.
|
||||||
|
|
||||||
|
|||||||
129
android_app_integration.md
Normal file
129
android_app_integration.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Android App Integration Guide Dashboard Proxy
|
||||||
|
|
||||||
|
This document outlines how to build an Android application that consumes the data provided by your AC Infinity Proxy API.
|
||||||
|
|
||||||
|
## Base Configuration
|
||||||
|
|
||||||
|
* **Base URL**: `https://dev.seedheads.de/ac/`
|
||||||
|
* **Protocol**: HTTPS
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The proxy server exposes two main endpoints that return JSON data.
|
||||||
|
|
||||||
|
### 1. Get All Devices
|
||||||
|
Retrieves a list of all devices and their ports currently stored in the database.
|
||||||
|
|
||||||
|
* **Endpoint**: `GET /api/devices`
|
||||||
|
* **Full URL**: `https://dev.seedheads.de/ac/api/devices`
|
||||||
|
* **Response Format**: JSON Array
|
||||||
|
* **Example Response**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"dev_name": "Tent Controller",
|
||||||
|
"port": 1,
|
||||||
|
"port_name": "Fan Inline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dev_name": "Tent Controller",
|
||||||
|
"port": 2,
|
||||||
|
"port_name": "Light Top"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Get Historical Data
|
||||||
|
Retrieves historical sensor readings for a specific device and port.
|
||||||
|
|
||||||
|
* **Endpoint**: `GET /api/history`
|
||||||
|
* **Full URL**: `https://dev.seedheads.de/ac/api/history`
|
||||||
|
* **Query Parameters**:
|
||||||
|
* `devName` (string, required): The name of the controller (e.g., "Tent Controller"). URL-encoded.
|
||||||
|
* `port` (integer, required): The port number (e.g., `1`).
|
||||||
|
* `range` (string, optional): The time range. Defaults to `day`.
|
||||||
|
* `day`: Last 24 hours.
|
||||||
|
* `week`: Last 7 days.
|
||||||
|
* `month`: Last 30 days.
|
||||||
|
|
||||||
|
* **Example Request**:
|
||||||
|
`GET https://dev.seedheads.de/ac/api/history?devName=Tent%20Controller&port=1&range=day`
|
||||||
|
|
||||||
|
* **Response Format**: JSON Array of objects
|
||||||
|
* **Example Response**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"timestamp": "2023-10-27T10:00:00Z",
|
||||||
|
"temp_c": 24.5,
|
||||||
|
"humidity": 60.2,
|
||||||
|
"vpd": 1.1,
|
||||||
|
"fan_speed": 5,
|
||||||
|
"on_speed": 0,
|
||||||
|
"off_speed": 0
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
*Note: Timestamps are returned in UTC (ending in 'Z'). You must convert them to the local timezone within the Android app.*
|
||||||
|
|
||||||
|
## Android Implementation Recommendations
|
||||||
|
|
||||||
|
### Networking Library
|
||||||
|
Use **Retrofit** with **OkHttp** for robust networking. It simplifies URL handling, parameter encoding, and JSON parsing.
|
||||||
|
|
||||||
|
**Dependencies (`build.gradle`):**
|
||||||
|
```groovy
|
||||||
|
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||||
|
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Models (Kotlin)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class Device(
|
||||||
|
@SerializedName("dev_name") val devName: String,
|
||||||
|
@SerializedName("port") val port: Int,
|
||||||
|
@SerializedName("port_name") val portName: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Reading(
|
||||||
|
val timestamp: String,
|
||||||
|
@SerializedName("temp_c") val tempC: Float,
|
||||||
|
@SerializedName("humidity") val humidity: Float,
|
||||||
|
@SerializedName("vpd") val vpd: Float,
|
||||||
|
@SerializedName("fan_speed") val fanSpeed: Int
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retrofit Service Interface
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface AcApi {
|
||||||
|
@GET("api/devices")
|
||||||
|
suspend fun getDevices(): List<Device>
|
||||||
|
|
||||||
|
@GET("api/history")
|
||||||
|
suspend fun getHistory(
|
||||||
|
@Query("devName") devName: String,
|
||||||
|
@Query("port") port: Int,
|
||||||
|
@Query("range") range: String = "day"
|
||||||
|
): List<Reading>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handling Timezones
|
||||||
|
Since the API returns UTC timestamps (e.g., `2023-10-27T10:00:00Z`), use `java.time` (API 26+) to format them for the user's local device time.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val instant = Instant.parse(reading.timestamp)
|
||||||
|
val localDateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
* **Authentication**: Currently, the proxy at `https://dev.seedheads.de/ac/` is publicly accessible (if not protected by Basic Auth in Nginx). If you add Basic Auth to Nginx, you will need to add an `Authorization` header to your Retrofit client.
|
||||||
|
* **HTTPS**: Ensure your Android app allows HTTPS connections (default behavior).
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
* **Push Notifications**: The current API is poll-based. For real-time alerts (e.g., high temp), you would need to implement a background worker in Android (`WorkManager`) to poll the API periodically, or implement Firebase Cloud Messaging (FCM) on the Node.js server.
|
||||||
@@ -232,7 +232,7 @@ app.get('/api/history', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
SELECT timestamp, temp_c, humidity, vpd, fan_speed, on_speed
|
SELECT timestamp || 'Z' as timestamp, temp_c, humidity, vpd, fan_speed, on_speed
|
||||||
FROM readings
|
FROM readings
|
||||||
WHERE dev_name = ? AND port = ? AND timestamp >= datetime('now', ?)
|
WHERE dev_name = ? AND port = ? AND timestamp >= datetime('now', ?)
|
||||||
ORDER BY timestamp ASC
|
ORDER BY timestamp ASC
|
||||||
@@ -252,6 +252,12 @@ app.get('/api/history', (req, res) => {
|
|||||||
const devMiddleware = webpackDevMiddleware(compiler, {
|
const devMiddleware = webpackDevMiddleware(compiler, {
|
||||||
publicPath: '/',
|
publicPath: '/',
|
||||||
writeToDisk: false,
|
writeToDisk: false,
|
||||||
|
headers: (req, res, context) => {
|
||||||
|
// Set cache headers for hashed bundle files (immutable)
|
||||||
|
if (req.url && req.url.match(/\.[a-f0-9]{8,}\.(js|css)$/i)) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
app.use(devMiddleware);
|
app.use(devMiddleware);
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function App() {
|
|||||||
<AppBar position="static">
|
<AppBar position="static">
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||||
AC Infinity Dashboard
|
Tischlerei Dashboard
|
||||||
</Typography>
|
</Typography>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|||||||
@@ -67,18 +67,18 @@ export default function ControllerCard({ controllerName, ports, range }) {
|
|||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{ports.map((port) => {
|
{ports.map((port) => {
|
||||||
const isLight = port.port_name && port.port_name.toLowerCase().includes('light');
|
const isLight = port.port_name && port.port_name.toLowerCase().includes('light');
|
||||||
const levelTitle = isLight ? 'Brightness' : 'Fan Speed';
|
const isCO2 = port.port_name && port.port_name.toLowerCase().includes('co2');
|
||||||
const pData = portData[port.port] || [];
|
const pData = portData[port.port] || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid item xs={12} md={6} lg={4} key={port.port}>
|
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={port.port}>
|
||||||
<Card variant="outlined" sx={{ bgcolor: '#f8f9fa' }}>
|
<Card variant="outlined" sx={{ bgcolor: '#f8f9fa' }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
{port.port_name || `Port ${port.port}`}
|
{port.port_name || `Port ${port.port}`}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ height: 250 }}>
|
<Box sx={{ height: 250 }}>
|
||||||
<LevelChart data={pData} isLight={isLight} range={range} />
|
<LevelChart data={pData} isLight={isLight} isCO2={isCO2} range={range} />
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ ChartJS.register(
|
|||||||
Legend
|
Legend
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function LevelChart({ data, isLight, range }) {
|
export default function LevelChart({ data, isLight, isCO2, range }) {
|
||||||
if (!data || data.length === 0) return null;
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
const formatDateLabel = (timestamp) => {
|
const formatDateLabel = (timestamp) => {
|
||||||
@@ -34,8 +34,9 @@ export default function LevelChart({ data, isLight, range }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const levelLabel = isLight ? 'Brightness' : 'Fan Speed';
|
// Determine label and color based on sensor type
|
||||||
const levelColor = isLight ? '#ffcd56' : '#9966ff';
|
const levelLabel = isCO2 ? 'CO2 (ppm)' : (isLight ? 'Brightness' : 'Fan Speed');
|
||||||
|
const levelColor = isCO2 ? '#4caf50' : (isLight ? '#ffcd56' : '#9966ff');
|
||||||
|
|
||||||
const chartData = {
|
const chartData = {
|
||||||
labels: data.map(d => formatDateLabel(d.timestamp)),
|
labels: data.map(d => formatDateLabel(d.timestamp)),
|
||||||
@@ -45,13 +46,18 @@ export default function LevelChart({ data, isLight, range }) {
|
|||||||
data: data.map(d => d.fan_speed),
|
data: data.map(d => d.fan_speed),
|
||||||
borderColor: levelColor,
|
borderColor: levelColor,
|
||||||
backgroundColor: levelColor,
|
backgroundColor: levelColor,
|
||||||
stepped: true,
|
stepped: !isCO2, // CO2 uses smooth lines
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 1
|
pointRadius: 1
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// CO2 needs different Y-axis scale (ppm range)
|
||||||
|
const yScale = isCO2
|
||||||
|
? { suggestedMin: 400, suggestedMax: 2000 }
|
||||||
|
: { suggestedMin: 0, suggestedMax: 10, ticks: { stepSize: 1 } };
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -67,11 +73,7 @@ export default function LevelChart({ data, isLight, range }) {
|
|||||||
maxTicksLimit: 8
|
maxTicksLimit: 8
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
y: {
|
y: yScale
|
||||||
suggestedMin: 0,
|
|
||||||
suggestedMax: 10,
|
|
||||||
ticks: { stepSize: 1 }
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AC Infinity Dashboard</title>
|
<title>Tischlerei Dashboard</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ export default {
|
|||||||
entry: './src/client/index.js',
|
entry: './src/client/index.js',
|
||||||
output: {
|
output: {
|
||||||
path: path.resolve(__dirname, 'dist'),
|
path: path.resolve(__dirname, 'dist'),
|
||||||
filename: 'bundle.js',
|
filename: 'bundle.[contenthash].js',
|
||||||
publicPath: '/ac/'
|
publicPath: '/ac/',
|
||||||
|
clean: true // Clean dist folder on rebuild
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
|
|||||||
Reference in New Issue
Block a user