init
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.pio
|
||||
.vscode/.browse.c_cpp.db*
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
10
.vscode/extensions.json
vendored
Normal file
10
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||
// for the documentation about the extensions.json format
|
||||
"recommendations": [
|
||||
"platformio.platformio-ide"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"ms-vscode.cpptools-extension-pack"
|
||||
]
|
||||
}
|
||||
28
GUEST_MANUAL.md
Normal file
28
GUEST_MANUAL.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Welcome to the Party Cam!
|
||||
|
||||
Snap a photo, get a receipt, and view the digital archive instantly.
|
||||
|
||||
---
|
||||
|
||||
### How to use
|
||||
|
||||
1. **Frame your shot.** (The lens is slightly wide-angle!)
|
||||
2. **Press the Button.**
|
||||
3. **Wait ~5 seconds.**
|
||||
- **Print:** Collect your physical photo.
|
||||
- **Upload:** The photo is magically sent to the gallery.
|
||||
|
||||
---
|
||||
|
||||
### View the Live Gallery
|
||||
|
||||
Want to save the photo to your phone?
|
||||
|
||||
1. Connect to the WiFi: **[Your_WiFi_Name]**
|
||||
2. Scan this code or go to:
|
||||
**http://[YOUR_LAPTOP_IP]:3000/gallery**
|
||||
3. Password: **partytime**
|
||||
|
||||
_(Ask the host if you can't connect!)_
|
||||
|
||||
---
|
||||
82
README.md
Normal file
82
README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Party Cam: Instant Thermal Camera & Digital Gallery
|
||||
|
||||
A DIY point-and-shoot camera that prints dithered lo-fi photos on thermal receipts and instantly uploads high-res copies to a local Rust server.
|
||||
|
||||
## Hardware BOM
|
||||
|
||||
- **ESP32-CAM**
|
||||
- **Thermal Printer** (TTL Serial, 5V-9V)
|
||||
- **Batteries** (Recommended: 2x 18650 Li-Ion in series = 7.4V, powering printer directly + buck converter to 5V for ESP32)
|
||||
- **Button** (Tactile switch)
|
||||
- **Programmer**: FTDI Adapter or Arduino Uno (for flashing)
|
||||
|
||||
## Wiring
|
||||
|
||||
### 1. Camera & Printer
|
||||
|
||||
| ESP32-CAM Pin | Component | Function |
|
||||
| :---------------- | :--------- | :--------------------------------- |
|
||||
| **5V** | PSU 5V | Power In |
|
||||
| **GND** | PSU GND | Common Ground |
|
||||
| **GPIO 12** | Button | Trigger (Connect other leg to GND) |
|
||||
| **GPIO 14** (U1T) | Printer RX | Data TO Printer |
|
||||
| **GPIO 15** (U1R) | Printer TX | Data FROM Printer |
|
||||
| **GPIO 4** | Flash LED | (Optional) Built-in Flash |
|
||||
|
||||
### 2. Flashing (Using Arduino Uno as Bridge)
|
||||
|
||||
_See "How to Flash" section below._
|
||||
|
||||
---
|
||||
|
||||
## Setup Guide
|
||||
|
||||
### A. The Server (Rust)
|
||||
|
||||
The server receives images and hosts the gallery for guests.
|
||||
|
||||
1. **Install Rust:** `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`
|
||||
2. **Configure:**
|
||||
Create a `.env` file in the `server` folder:
|
||||
```ini
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=3000
|
||||
GALLERY_PASSWORD=partytime
|
||||
UPLOAD_DIR=./uploads
|
||||
```
|
||||
3. **Run:**
|
||||
```bash
|
||||
cd server
|
||||
cargo run --release
|
||||
```
|
||||
_Server will listen on port 3000._
|
||||
|
||||
### B. The Camera (Firmware)
|
||||
|
||||
1. **Open Project:** Open this folder in VS Code with the PlatformIO extension.
|
||||
2. **Flash Mode Wiring (Arduino Uno Method):**
|
||||
- **Arduino RESET** -> **Arduino GND** (Bypasses Arduino chip).
|
||||
- **Arduino Pin 0 (RX)** -> **ESP32 U0R** (Direct passthrough).
|
||||
- **Arduino Pin 1 (TX)** -> **ESP32 U0T** (Direct passthrough).
|
||||
- **ESP32 IO0** -> **ESP32 GND** (Enables Download Mode).
|
||||
3. **Upload:**
|
||||
- Connect via USB.
|
||||
- Click "Upload".
|
||||
- Press the **RST** button on ESP32 when "Connecting..." appears.
|
||||
4. **Run Mode:**
|
||||
- **Disconnect IO0 from GND.**
|
||||
- Press **RST** to boot.
|
||||
|
||||
---
|
||||
|
||||
## Configuration (First Boot)
|
||||
|
||||
1. **Power on** the camera.
|
||||
2. Connect your phone to the WiFi network: `PartyCam-Setup`.
|
||||
3. A captive portal should open (or go to `192.168.4.1`).
|
||||
4. **WiFi:** Select the venue's WiFi (or your phone hotspot).
|
||||
5. **Server URL:** Enter your laptop's IP (e.g., `http://192.168.1.15:3000/upload`).
|
||||
6. **Settings:** Adjust Contrast/Heat if the print is too faint or dark.
|
||||
7. **Save & Reboot.**
|
||||
|
||||
The camera is now live!
|
||||
37
include/README
Normal file
37
include/README
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
This directory is intended for project header files.
|
||||
|
||||
A header file is a file containing C declarations and macro definitions
|
||||
to be shared between several project source files. You request the use of a
|
||||
header file in your project source file (C, C++, etc) located in `src` folder
|
||||
by including it, with the C preprocessing directive `#include'.
|
||||
|
||||
```src/main.c
|
||||
|
||||
#include "header.h"
|
||||
|
||||
int main (void)
|
||||
{
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Including a header file produces the same results as copying the header file
|
||||
into each source file that needs it. Such copying would be time-consuming
|
||||
and error-prone. With a header file, the related declarations appear
|
||||
in only one place. If they need to be changed, they can be changed in one
|
||||
place, and programs that include the header file will automatically use the
|
||||
new version when next recompiled. The header file eliminates the labor of
|
||||
finding and changing all the copies as well as the risk that a failure to
|
||||
find one copy will result in inconsistencies within a program.
|
||||
|
||||
In C, the convention is to give header files names that end with `.h'.
|
||||
|
||||
Read more about using header files in official GCC documentation:
|
||||
|
||||
* Include Syntax
|
||||
* Include Operation
|
||||
* Once-Only Headers
|
||||
* Computed Includes
|
||||
|
||||
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
|
||||
31
include/camera_service.h
Normal file
31
include/camera_service.h
Normal file
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
#include "esp_camera.h"
|
||||
|
||||
class CameraService {
|
||||
public:
|
||||
class Frame {
|
||||
camera_fb_t* fb;
|
||||
public:
|
||||
Frame(camera_fb_t* _fb) : fb(_fb) {}
|
||||
~Frame() {
|
||||
if (fb) {
|
||||
esp_camera_fb_return(fb);
|
||||
}
|
||||
}
|
||||
|
||||
Frame(const Frame&) = delete;
|
||||
Frame& operator=(const Frame&) = delete;
|
||||
Frame(Frame&& other) noexcept : fb(other.fb) {
|
||||
other.fb = nullptr;
|
||||
}
|
||||
|
||||
bool isValid() const { return fb != nullptr; }
|
||||
uint8_t* getData() const { return fb ? fb->buf : nullptr; }
|
||||
size_t getWidth() const { return fb ? fb->width : 0; }
|
||||
size_t getHeight() const { return fb ? fb->height : 0; }
|
||||
size_t getLength() const { return fb ? fb->len : 0; }
|
||||
};
|
||||
|
||||
static void init();
|
||||
static Frame capture();
|
||||
};
|
||||
17
include/config.h
Normal file
17
include/config.h
Normal file
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
namespace Config {
|
||||
// Hardware Pins (ESP32-CAM AI-Thinker)
|
||||
constexpr int PIN_CAM_PWDN = 32;
|
||||
constexpr int PIN_CAM_RESET = -1;
|
||||
// ... (Add standard camera pins here to keep main clean) ...
|
||||
|
||||
// Printer Pins
|
||||
constexpr int PIN_PRINTER_RX = 14;
|
||||
constexpr int PIN_PRINTER_TX = 15;
|
||||
|
||||
// Settings
|
||||
constexpr int PRINTER_WIDTH = 384;
|
||||
constexpr int BAUD_RATE = 9600; // Check your printer specs (some are 19200)
|
||||
}
|
||||
11
include/image_processor.h
Normal file
11
include/image_processor.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
#include "camera_service.h"
|
||||
#include "printer_service.h"
|
||||
#include "settings_service.h"
|
||||
|
||||
class ImageProcessor
|
||||
{
|
||||
public:
|
||||
static void processAndPrint(const CameraService::Frame &frame, PrinterService &printer, const AppSettings &settings);
|
||||
static void uploadImage(const uint8_t *bitmap, int size, const String &url);
|
||||
};
|
||||
31
include/printer_service.h
Normal file
31
include/printer_service.h
Normal file
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <Adafruit_Thermal.h>
|
||||
#include "config.h"
|
||||
|
||||
class PrinterService
|
||||
{
|
||||
private:
|
||||
HardwareSerial *printerSerial;
|
||||
Adafruit_Thermal *printer;
|
||||
|
||||
static PrinterService *instance;
|
||||
|
||||
PrinterService();
|
||||
|
||||
public:
|
||||
PrinterService(const PrinterService &) = delete;
|
||||
PrinterService &operator=(const PrinterService &) = delete;
|
||||
|
||||
static void init();
|
||||
static PrinterService &getInstance();
|
||||
|
||||
void wake();
|
||||
void sleep();
|
||||
void feed(int lines);
|
||||
|
||||
void setHeat(uint8_t heatTime);
|
||||
|
||||
void printBitmap(int w, int h, const uint8_t *bitmap);
|
||||
void printText(const String &text);
|
||||
};
|
||||
41
include/settings_service.h
Normal file
41
include/settings_service.h
Normal file
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <Preferences.h>
|
||||
#include <WebServer.h>
|
||||
|
||||
struct AppSettings
|
||||
{
|
||||
// Camera
|
||||
int contrast = 0; // -2 to 2
|
||||
int brightness = 0; // -2 to 2
|
||||
bool vFlip = false;
|
||||
bool hMirror = false;
|
||||
|
||||
// Printer
|
||||
int heatTime = 120; // 0-255 (Higher = darker but slower)
|
||||
int heatInterval = 50; // 0-255
|
||||
|
||||
// Upload
|
||||
String uploadUrl = ""; // e.g., "http://192.168.1.10:3000/upload"
|
||||
bool enableUpload = false;
|
||||
};
|
||||
|
||||
class SettingsService
|
||||
{
|
||||
private:
|
||||
Preferences prefs;
|
||||
WebServer server;
|
||||
AppSettings currentSettings;
|
||||
bool wifiConnected = false;
|
||||
|
||||
void setupRoutes();
|
||||
void loadSettings();
|
||||
|
||||
public:
|
||||
SettingsService();
|
||||
void begin();
|
||||
void handle();
|
||||
|
||||
AppSettings &get() { return currentSettings; }
|
||||
void save();
|
||||
};
|
||||
46
lib/README
Normal file
46
lib/README
Normal file
@@ -0,0 +1,46 @@
|
||||
|
||||
This directory is intended for project specific (private) libraries.
|
||||
PlatformIO will compile them to static libraries and link into the executable file.
|
||||
|
||||
The source code of each library should be placed in a separate directory
|
||||
("lib/your_library_name/[Code]").
|
||||
|
||||
For example, see the structure of the following example libraries `Foo` and `Bar`:
|
||||
|
||||
|--lib
|
||||
| |
|
||||
| |--Bar
|
||||
| | |--docs
|
||||
| | |--examples
|
||||
| | |--src
|
||||
| | |- Bar.c
|
||||
| | |- Bar.h
|
||||
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
|
||||
| |
|
||||
| |--Foo
|
||||
| | |- Foo.c
|
||||
| | |- Foo.h
|
||||
| |
|
||||
| |- README --> THIS FILE
|
||||
|
|
||||
|- platformio.ini
|
||||
|--src
|
||||
|- main.c
|
||||
|
||||
Example contents of `src/main.c` using Foo and Bar:
|
||||
```
|
||||
#include <Foo.h>
|
||||
#include <Bar.h>
|
||||
|
||||
int main (void)
|
||||
{
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
The PlatformIO Library Dependency Finder will find automatically dependent
|
||||
libraries by scanning project source files.
|
||||
|
||||
More information about PlatformIO Library Dependency Finder
|
||||
- https://docs.platformio.org/page/librarymanager/ldf.html
|
||||
18
platformio.ini
Normal file
18
platformio.ini
Normal file
@@ -0,0 +1,18 @@
|
||||
; PlatformIO Project Configuration File
|
||||
;
|
||||
; Build options: build flags, source filter
|
||||
; Upload options: custom upload port, speed and extra flags
|
||||
; Library options: dependencies, extra library storages
|
||||
; Advanced options: extra scripting
|
||||
;
|
||||
; Please visit documentation for the other options and examples
|
||||
; https://docs.platformio.org/page/projectconf.html
|
||||
|
||||
[env:4d_systems_esp32s3_gen4_r8n16]
|
||||
platform = espressif32
|
||||
board = 4d_systems_esp32s3_gen4_r8n16
|
||||
framework = arduino
|
||||
lib_deps =
|
||||
espressif/esp32-camera@^2.0.4
|
||||
adafruit/Adafruit Thermal Printer Library
|
||||
tzapu/WiFiManager
|
||||
5
server/.env.example
Normal file
5
server/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=3000
|
||||
GALLERY_PASSWORD=partytime
|
||||
UPLOAD_DIR=./uploads
|
||||
RUST_LOG=info,partycam_server=debug
|
||||
4
server/.gitignore
vendored
Normal file
4
server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
/uploads
|
||||
|
||||
.env
|
||||
1901
server/Cargo.lock
generated
Normal file
1901
server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
server/Cargo.toml
Normal file
16
server/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
axum = "0.8.8"
|
||||
base64 = "0.22.1"
|
||||
chrono = "0.4.42"
|
||||
dotenvy = "0.15.7"
|
||||
image = { version = "0.25.9", features = ["png"] }
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tower-http = { version = "0.6.8", features = ["fs", "trace", "cors"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "fmt"] }
|
||||
44
server/Dockerfile
Normal file
44
server/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# 1. Builder Stage
|
||||
FROM rust:1.92 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# --- OPTIMIZATION: Dependency Caching ---
|
||||
# 1. Create a dummy project to cache dependencies
|
||||
RUN mkdir src
|
||||
RUN echo "fn main() {}" > src/main.rs
|
||||
|
||||
# 2. Copy manifests
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
# 3. Build only dependencies (this layer is cached until Cargo.toml changes)
|
||||
RUN cargo build --release
|
||||
|
||||
# 4. Now remove dummy source and copy your actual source
|
||||
# We 'touch' main.rs to ensure Cargo realizes it needs a rebuild
|
||||
RUN rm -rf src
|
||||
COPY . .
|
||||
RUN touch src/main.rs
|
||||
|
||||
# 5. Build the actual application
|
||||
RUN cargo build --release
|
||||
|
||||
# 2. Runtime Stage
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL & CA Certificates
|
||||
RUN apt-get update && \
|
||||
apt-get install -y libssl3 ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy the binary from builder
|
||||
# Note: Ensure the binary name matches 'package.name' in Cargo.toml ("server")
|
||||
COPY --from=builder /app/target/release/server .
|
||||
|
||||
# Documentation for ports
|
||||
EXPOSE 3000
|
||||
|
||||
# Run the binary
|
||||
CMD ["./server"]
|
||||
16
server/compose.yml
Normal file
16
server/compose.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
server:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
# MAPPING: [Host Folder] : [Container Folder]
|
||||
# You can change './party_images' to whatever folder on your laptop you want.
|
||||
- ./party_images:/app/uploads
|
||||
environment:
|
||||
# We FORCE the internal path to match the volume mount above.
|
||||
# This overrides whatever is in your .env file for safety.
|
||||
- UPLOAD_DIR=/app/uploads
|
||||
- SERVER_HOST=0.0.0.0
|
||||
- GALLERY_PASSWORD=partytime
|
||||
restart: unless-stopped
|
||||
102
server/src/gallery.html
Normal file
102
server/src/gallery.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Party Cam Gallery</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
image-rendering: pixelated; /* Keeps that retro look crisp */
|
||||
}
|
||||
|
||||
.meta {
|
||||
padding: 10px;
|
||||
font-size: 0.8em;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>📸 Live Party Gallery</h1>
|
||||
<div id="gallery" class="gallery"></div>
|
||||
|
||||
<script>
|
||||
const gallery = document.getElementById("gallery");
|
||||
let currentImages = new Set();
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await fetch("/gallery/data");
|
||||
if (res.status === 401) window.location.reload(); // Trigger auth prompt
|
||||
const images = await res.json();
|
||||
|
||||
// Simple diffing to avoid reloading existing images
|
||||
images.forEach((img) => {
|
||||
if (!currentImages.has(img)) {
|
||||
addCard(img);
|
||||
currentImages.add(img);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function addCard(filename) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "card";
|
||||
div.innerHTML = `
|
||||
<a href="/gallery/img/${filename}" target="_blank">
|
||||
<img src="/gallery/img/${filename}" loading="lazy" alt="Party Photo">
|
||||
</a>
|
||||
<div class="meta">${filename
|
||||
.replace("capture_", "")
|
||||
.replace(".png", "")
|
||||
.replace("_", " ")}</div>
|
||||
`;
|
||||
// Prepend to show new ones at top
|
||||
gallery.prepend(div);
|
||||
}
|
||||
|
||||
// Initial load and poll every 5 seconds
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
183
server/src/main.rs
Normal file
183
server/src/main.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use std::{env, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::{StatusCode, header},
|
||||
middleware::{self, Next},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use base64::Engine;
|
||||
|
||||
use image::{GrayImage, Luma};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
upload_dir: PathBuf,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let host = env::var("SERVER_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
|
||||
let port = env::var("SERVER_PORT").unwrap_or_else(|_| "3000".to_string());
|
||||
let password = env::var("GALLERY_PASSWORD").expect("GALLERY_PASSWORD must be set");
|
||||
let upload_dir =
|
||||
PathBuf::from(env::var("UPLOAD_DIR").unwrap_or_else(|_| "./uploads".to_string()));
|
||||
|
||||
tokio::fs::create_dir_all(&upload_dir).await?;
|
||||
let state = Arc::new(AppState {
|
||||
upload_dir,
|
||||
password,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/upload", post(upload_handler))
|
||||
.nest("/gallery", protected_routes(state.clone()))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
|
||||
tracing::info!("🚀 Server running on http://{}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn protected_routes(state: Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/", get(gallery_view))
|
||||
.route("/data", get(gallery_data))
|
||||
.route("/img/{filename}", get(serve_image))
|
||||
.route_layer(middleware::from_fn_with_state(state, basic_auth))
|
||||
}
|
||||
|
||||
async fn upload_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
body: Bytes,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S");
|
||||
let filename = format!("capture_{}.png", timestamp);
|
||||
let path = state.upload_dir.join(&filename);
|
||||
let path_clone = path.clone();
|
||||
|
||||
tracing::info!("Receiving upload: {} bytes", body.len());
|
||||
|
||||
tokio::task::spawn_blocking(move || convert_and_save(&body, &path))
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to process image: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
tracing::info!("Saved: {:?}", path_clone);
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
async fn gallery_view() -> Html<&'static str> {
|
||||
Html(include_str!("gallery.html"))
|
||||
}
|
||||
|
||||
async fn gallery_data(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let mut entries = tokio::fs::read_dir(&state.upload_dir).await.unwrap();
|
||||
let mut images = Vec::new();
|
||||
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
if let Some(ext) = path.extension() {
|
||||
if ext == "png" {
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
images.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
images.sort_by(|a, b| b.cmp(a));
|
||||
axum::Json(images)
|
||||
}
|
||||
|
||||
async fn serve_image(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(filename): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let path = state.upload_dir.join(&filename);
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => ([(header::CONTENT_TYPE, "image/png")], bytes).into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn basic_auth(
|
||||
State(state): State<Arc<AppState>>,
|
||||
req: axum::extract::Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Check for "Authorization" header
|
||||
if let Some(auth_header) = req.headers().get("Authorization") {
|
||||
if let Ok(auth_str) = auth_header.to_str() {
|
||||
if auth_str.starts_with("Basic ") {
|
||||
let payload = &auth_str[6..]; // Remove "Basic "
|
||||
if let Ok(decoded) = base64::prelude::BASE64_STANDARD.decode(payload) {
|
||||
if let Ok(cred) = String::from_utf8(decoded) {
|
||||
if let Some((_user, pass)) = cred.split_once(':') {
|
||||
if pass == state.password {
|
||||
return next.run(req).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return 401 Challenge if auth fails
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[(header::WWW_AUTHENTICATE, "Basic realm=\"PartyCam\"")],
|
||||
"Unauthorized",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn convert_and_save(data: &[u8], path: &PathBuf) -> anyhow::Result<()> {
|
||||
// Dimensions from ESP32 code (240x320)
|
||||
let width = 240;
|
||||
let height = 320;
|
||||
|
||||
let mut img = GrayImage::new(width, height);
|
||||
|
||||
// Unpack bits
|
||||
// The ESP32 code: bitmap[y * rowBytes + (x / 8)] |= (1 << (7 - (x % 8)));
|
||||
// We reverse this logic.
|
||||
let row_bytes = (width + 7) / 8;
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let byte_index = (y * row_bytes + (x / 8)) as usize;
|
||||
if byte_index < data.len() {
|
||||
let byte = data[byte_index];
|
||||
let bit = (byte >> (7 - (x % 8))) & 1;
|
||||
|
||||
// 1 = Black (Heat), 0 = White
|
||||
// In PNG Gray8: 0 = Black, 255 = White.
|
||||
// So if bit is 1, we want 0 (Black).
|
||||
let pixel_val = if bit == 1 { 0 } else { 255 };
|
||||
img.put_pixel(x, y, Luma([pixel_val]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img.save(path)?;
|
||||
Ok(())
|
||||
}
|
||||
85
src/camera_service.cpp
Normal file
85
src/camera_service.cpp
Normal file
@@ -0,0 +1,85 @@
|
||||
#include <Arduino.h>
|
||||
#include "camera_service.h"
|
||||
#include "config.h"
|
||||
|
||||
#define PWDN_GPIO_NUM 32
|
||||
#define RESET_GPIO_NUM -1
|
||||
#define XCLK_GPIO_NUM 0
|
||||
#define SIOD_GPIO_NUM 26
|
||||
#define SIOC_GPIO_NUM 27
|
||||
|
||||
#define Y9_GPIO_NUM 35
|
||||
#define Y8_GPIO_NUM 34
|
||||
#define Y7_GPIO_NUM 39
|
||||
#define Y6_GPIO_NUM 36
|
||||
#define Y5_GPIO_NUM 21
|
||||
#define Y4_GPIO_NUM 19
|
||||
#define Y3_GPIO_NUM 18
|
||||
#define Y2_GPIO_NUM 5
|
||||
#define VSYNC_GPIO_NUM 25
|
||||
#define HREF_GPIO_NUM 23
|
||||
#define PCLK_GPIO_NUM 22
|
||||
|
||||
void CameraService::init()
|
||||
{
|
||||
camera_config_t config;
|
||||
|
||||
config.ledc_channel = LEDC_CHANNEL_0;
|
||||
config.ledc_timer = LEDC_TIMER_0;
|
||||
config.pin_d0 = Y2_GPIO_NUM;
|
||||
config.pin_d1 = Y3_GPIO_NUM;
|
||||
config.pin_d2 = Y4_GPIO_NUM;
|
||||
config.pin_d3 = Y5_GPIO_NUM;
|
||||
config.pin_d4 = Y6_GPIO_NUM;
|
||||
config.pin_d5 = Y7_GPIO_NUM;
|
||||
config.pin_d6 = Y8_GPIO_NUM;
|
||||
config.pin_d7 = Y9_GPIO_NUM;
|
||||
config.pin_xclk = XCLK_GPIO_NUM;
|
||||
config.pin_pclk = PCLK_GPIO_NUM;
|
||||
config.pin_vsync = VSYNC_GPIO_NUM;
|
||||
config.pin_href = HREF_GPIO_NUM;
|
||||
config.pin_sscb_sda = SIOD_GPIO_NUM;
|
||||
config.pin_sscb_scl = SIOC_GPIO_NUM;
|
||||
config.pin_pwdn = PWDN_GPIO_NUM;
|
||||
config.pin_reset = RESET_GPIO_NUM;
|
||||
|
||||
config.xclk_freq_hz = 20000000;
|
||||
|
||||
config.pixel_format = PIXFORMAT_GRAYSCALE;
|
||||
|
||||
config.frame_size = FRAMESIZE_QVGA;
|
||||
|
||||
config.jpeg_quality = 12;
|
||||
config.fb_count = 1; // how many frame buffers (we only care about recent frame)
|
||||
|
||||
if (psramFound())
|
||||
{
|
||||
config.jpeg_quality = 10;
|
||||
config.fb_count = 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback for non-PSRAM boards (rare for ESP32-CAM)
|
||||
config.jpeg_quality = 12;
|
||||
config.fb_count = 1;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_camera_init(&config);
|
||||
if (err != ESP_OK)
|
||||
{
|
||||
Serial.printf("[ERR] Camera Init Failed: 0x%x\n", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
CameraService::Frame CameraService::capture()
|
||||
{
|
||||
camera_fb_t *fb = esp_camera_fb_get();
|
||||
|
||||
if (!fb)
|
||||
{
|
||||
Serial.println("[ERR] Camera Capture Failed");
|
||||
}
|
||||
|
||||
return Frame(fb);
|
||||
}
|
||||
137
src/image_processor.cpp
Normal file
137
src/image_processor.cpp
Normal file
@@ -0,0 +1,137 @@
|
||||
#include "image_processor.h"
|
||||
#include <Arduino.h>
|
||||
#include <HTTPClient.h>
|
||||
|
||||
void ImageProcessor::processAndPrint(const CameraService::Frame &frame, PrinterService &printer, const AppSettings &settings)
|
||||
{
|
||||
if (!frame.isValid())
|
||||
return;
|
||||
|
||||
// 1. Setup Dimensions (Rotate 90 degrees)
|
||||
// Camera is 320x240 (Landscape) -> Printer is 384 wide (Portrait)
|
||||
// We will map Camera Height (240) to Printer Width.
|
||||
// Since 240 < 384, the image will be centered or small.
|
||||
// If you want full width, we must scale, but rotation is cheaper.
|
||||
const int width = frame.getHeight(); // 240
|
||||
const int height = frame.getWidth(); // 320
|
||||
|
||||
// 2. Allocate Buffer in PSRAM
|
||||
// We need int16_t to handle the overflow error during calculation
|
||||
size_t bufSize = width * height * sizeof(int16_t);
|
||||
int16_t *pixels = (int16_t *)heap_caps_malloc(bufSize, MALLOC_CAP_SPIRAM);
|
||||
|
||||
if (!pixels)
|
||||
{
|
||||
Serial.println("[ERR] PSRAM Alloc Failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Rotate & Load
|
||||
const uint8_t *src = frame.getData();
|
||||
// Pre-calculate to save cycles in loop
|
||||
const int srcWidth = frame.getWidth();
|
||||
|
||||
for (int y = 0; y < srcWidth; y++)
|
||||
{ // 320 (Source Y)
|
||||
for (int x = 0; x < width; x++)
|
||||
{ // 240 (Source X)
|
||||
// 90 Deg Rotation Logic
|
||||
// Dest(x, y) = Src(y, srcWidth - x - 1)
|
||||
// We are mapping the camera buffer to our Dithering Buffer
|
||||
uint8_t val = src[x * srcWidth + y];
|
||||
pixels[y * width + x] = static_cast<int16_t>(val);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Floyd-Steinberg Dithering
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
int16_t oldPixel = pixels[y * width + x];
|
||||
int16_t newPixel = (oldPixel > 128) ? 255 : 0;
|
||||
|
||||
pixels[y * width + x] = newPixel;
|
||||
int16_t error = oldPixel - newPixel;
|
||||
|
||||
// Distribute Error (Bit shifting is faster than division)
|
||||
// Right: 7/16
|
||||
if (x + 1 < width)
|
||||
pixels[y * width + (x + 1)] += (error * 7) >> 4;
|
||||
|
||||
// Bottom-Left: 3/16
|
||||
if (y + 1 < height)
|
||||
{
|
||||
if (x - 1 >= 0)
|
||||
pixels[(y + 1) * width + (x - 1)] += (error * 3) >> 4;
|
||||
|
||||
// Bottom: 5/16
|
||||
pixels[(y + 1) * width + x] += (error * 5) >> 4;
|
||||
|
||||
// Bottom-Right: 1/16
|
||||
if (x + 1 < width)
|
||||
pixels[(y + 1) * width + (x + 1)] += (error * 1) >> 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Pack & Print
|
||||
// We pack 8 pixels into 1 byte.
|
||||
// The Adafruit library expects a full bitmap array.
|
||||
// Optimization: We reuse the PSRAM allocation or alloc a smaller one.
|
||||
|
||||
// Row stride must be multiple of 8
|
||||
int rowBytes = (width + 7) / 8;
|
||||
size_t bitmapSize = rowBytes * height;
|
||||
uint8_t *bitmap = (uint8_t *)heap_caps_calloc(bitmapSize, 1, MALLOC_CAP_SPIRAM);
|
||||
|
||||
if (!bitmap)
|
||||
{
|
||||
free(pixels);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
if (pixels[y * width + x] == 0)
|
||||
{ // If black
|
||||
bitmap[y * rowBytes + (x / 8)] |= (1 << (7 - (x % 8)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
printer.setHeat(settings.heatTime);
|
||||
printer.wake();
|
||||
printer.printBitmap(width, height, bitmap);
|
||||
printer.feed(3);
|
||||
printer.sleep();
|
||||
|
||||
// 6. Cleanup (RAII handled elsewhere, but raw pointers need free)
|
||||
free(pixels);
|
||||
free(bitmap);
|
||||
}
|
||||
|
||||
void ImageProcessor::uploadImage(const uint8_t *bitmap, int size, const String &url)
|
||||
{
|
||||
if (WiFi.status() != WL_CONNECTED)
|
||||
return;
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(url);
|
||||
http.addHeader("Content-Type", "application/octet-stream");
|
||||
|
||||
int responseCode = http.POST((uint8_t *)bitmap, size);
|
||||
|
||||
if (responseCode > 0)
|
||||
{
|
||||
Serial.printf("[Upload] Success: %d\n", responseCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
Serial.printf("[Upload] Failed: %s\n", http.errorToString(responseCode).c_str());
|
||||
}
|
||||
|
||||
http.end();
|
||||
}
|
||||
73
src/main.cpp
Normal file
73
src/main.cpp
Normal file
@@ -0,0 +1,73 @@
|
||||
#include <Arduino.h>
|
||||
#include "camera_service.h"
|
||||
#include "printer_service.h"
|
||||
#include "image_processor.h"
|
||||
#include "settings_service.h"
|
||||
|
||||
const int BUTTON_PIN = 12; // Check your wiring
|
||||
unsigned long lastDebounceTime = 0;
|
||||
const unsigned long debounceDelay = 200;
|
||||
|
||||
SettingsService settingsService;
|
||||
|
||||
void applyCameraSettings()
|
||||
{
|
||||
sensor_t *s = esp_camera_sensor_get();
|
||||
if (s)
|
||||
{
|
||||
AppSettings &cfg = settingsService.get();
|
||||
s->set_contrast(s, cfg.contrast);
|
||||
s->set_brightness(s, cfg.brightness);
|
||||
s->set_vflip(s, cfg.vFlip ? 1 : 0);
|
||||
s->set_hmirror(s, cfg.hMirror ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
void setup()
|
||||
{
|
||||
Serial.begin(115200);
|
||||
Serial.println("Party Cam Starting...");
|
||||
|
||||
pinMode(BUTTON_PIN, INPUT_PULLUP);
|
||||
|
||||
CameraService::init();
|
||||
PrinterService::init();
|
||||
|
||||
settingsService.begin();
|
||||
|
||||
applyCameraSettings();
|
||||
|
||||
Serial.println("System Ready.");
|
||||
}
|
||||
|
||||
void loop()
|
||||
{
|
||||
|
||||
settingsService.handle();
|
||||
|
||||
if (digitalRead(BUTTON_PIN) == LOW)
|
||||
{
|
||||
if ((millis() - lastDebounceTime) > debounceDelay)
|
||||
{
|
||||
Serial.println("[State] Capturing...");
|
||||
|
||||
{
|
||||
auto frame = CameraService::capture();
|
||||
|
||||
if (frame.isValid())
|
||||
{
|
||||
Serial.println("[State] Processing & Printing...");
|
||||
|
||||
ImageProcessor::processAndPrint(frame, PrinterService::getInstance(), settingsService.get());
|
||||
}
|
||||
else
|
||||
{
|
||||
Serial.println("[Error] Capture Failed");
|
||||
}
|
||||
}
|
||||
|
||||
Serial.println("[State] Ready.");
|
||||
lastDebounceTime = millis();
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/printer_service.cpp
Normal file
60
src/printer_service.cpp
Normal file
@@ -0,0 +1,60 @@
|
||||
#include "printer_service.h"
|
||||
|
||||
PrinterService *PrinterService::instance = nullptr;
|
||||
|
||||
PrinterService::PrinterService()
|
||||
{
|
||||
printerSerial = new HardwareSerial(1); // replace it with not a magic number
|
||||
printer = new Adafruit_Thermal(printerSerial, Config::PIN_PRINTER_RX);
|
||||
}
|
||||
|
||||
void PrinterService::init()
|
||||
{
|
||||
if (instance == nullptr)
|
||||
{
|
||||
instance = new PrinterService();
|
||||
}
|
||||
|
||||
instance->printerSerial->begin(Config::BAUD_RATE, SERIAL_8N1, Config::PIN_PRINTER_RX, Config::PIN_PRINTER_TX);
|
||||
instance->printer->begin();
|
||||
}
|
||||
|
||||
PrinterService &PrinterService::getInstance()
|
||||
{
|
||||
if (!instance)
|
||||
init();
|
||||
return *instance;
|
||||
}
|
||||
|
||||
void PrinterService::wake()
|
||||
{
|
||||
printer->wake();
|
||||
}
|
||||
|
||||
void PrinterService::sleep()
|
||||
{
|
||||
printer->sleep();
|
||||
}
|
||||
|
||||
void PrinterService::feed(int lines)
|
||||
{
|
||||
printer->feed(lines);
|
||||
}
|
||||
|
||||
void PrinterService::printBitmap(int w, int h, const uint8_t *bitmap)
|
||||
{
|
||||
printer->printBitmap(w, h, bitmap);
|
||||
}
|
||||
|
||||
void PrinterService::printText(const String &text)
|
||||
{
|
||||
printer->println(text);
|
||||
}
|
||||
|
||||
void PrinterService::setHeat(uint8_t heatTime)
|
||||
{
|
||||
constexpr int heatDots = 11;
|
||||
constexpr int heatInterval = 50;
|
||||
|
||||
printer->setHeatConfig(heatDots, heatTime, heatInterval);
|
||||
}
|
||||
151
src/settings_service.cpp
Normal file
151
src/settings_service.cpp
Normal file
@@ -0,0 +1,151 @@
|
||||
#include <WiFi.h>
|
||||
#include <esp_camera.h>
|
||||
#include <WiFiManager.h>
|
||||
#include <ESPmDNS.h>
|
||||
#include "settings_service.h"
|
||||
|
||||
const char *index_html = R"rawliteral(
|
||||
<!DOCTYPE HTML><html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Party Cam Config</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 400px; margin: 0 auto; padding: 20px; }
|
||||
label { display: block; margin: 10px 0 5px; font-weight: bold; }
|
||||
input[type=number], input[type=text] { width: 100%; padding: 8px; margin-bottom: 10px; }
|
||||
input[type=submit] { background-color: #4CAF50; color: white; padding: 12px 20px; border: none; width: 100%; cursor: pointer; }
|
||||
.group { border: 1px solid #ddd; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Party Cam Settings</h2>
|
||||
<form action="/save" method="POST">
|
||||
<div class="group">
|
||||
<h3>Camera</h3>
|
||||
<label>Contrast (-2 to 2):</label> <input type="number" name="contrast" value="%CONTRAST%">
|
||||
<label>Brightness (-2 to 2):</label> <input type="number" name="brightness" value="%BRIGHTNESS%">
|
||||
<label><input type="checkbox" name="vflip" %VFLIP%> Vertical Flip</label><br>
|
||||
<label><input type="checkbox" name="hmirror" %HMIRROR%> Horizontal Mirror</label>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h3>Printer</h3>
|
||||
<label>Heat Time (Density):</label> <input type="number" name="heat" value="%HEAT%">
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h3>Upload (Webhook)</h3>
|
||||
<label>Upload URL:</label> <input type="text" name="url" value="%URL%">
|
||||
<label><input type="checkbox" name="upload" %UPLOAD%> Enable Upload</label>
|
||||
</div>
|
||||
|
||||
<input type="submit" value="Save Settings">
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
)rawliteral";
|
||||
|
||||
SettingsService::SettingsService() : server(80) {}
|
||||
|
||||
void SettingsService::begin()
|
||||
{
|
||||
// 1. Load saved values
|
||||
loadSettings();
|
||||
|
||||
WiFiManager wm;
|
||||
|
||||
wm.setConfigPortalTimeout(180); // 3 minutes
|
||||
|
||||
String apName = "PartyCam-Setup";
|
||||
Serial.println("Connecting to WiFi...");
|
||||
|
||||
if (!wm.autoConnect(apName.c_str()))
|
||||
{
|
||||
Serial.println("WiFi failed to connect (Timeout). Starting in Offline Mode.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Serial.println("WiFi Connected!");
|
||||
Serial.print("IP Address: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
|
||||
if (MDNS.begin("partycam"))
|
||||
{
|
||||
Serial.println("mDNS responder started: http://partycam.local");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Setup Web Server
|
||||
setupRoutes();
|
||||
server.begin();
|
||||
}
|
||||
|
||||
void SettingsService::loadSettings()
|
||||
{
|
||||
prefs.begin("partycam", false); // Namespace "partycam"
|
||||
currentSettings.contrast = prefs.getInt("contrast", 0);
|
||||
currentSettings.brightness = prefs.getInt("bright", 0);
|
||||
currentSettings.vFlip = prefs.getBool("vflip", false);
|
||||
currentSettings.hMirror = prefs.getBool("hmirror", false);
|
||||
currentSettings.heatTime = prefs.getInt("heat", 120);
|
||||
currentSettings.uploadUrl = prefs.getString("url", "");
|
||||
currentSettings.enableUpload = prefs.getBool("ena_upload", false);
|
||||
prefs.end();
|
||||
}
|
||||
|
||||
void SettingsService::save()
|
||||
{
|
||||
prefs.begin("partycam", false);
|
||||
prefs.putInt("contrast", currentSettings.contrast);
|
||||
prefs.putInt("bright", currentSettings.brightness);
|
||||
prefs.putBool("vflip", currentSettings.vFlip);
|
||||
prefs.putBool("hmirror", currentSettings.hMirror);
|
||||
prefs.putInt("heat", currentSettings.heatTime);
|
||||
prefs.putString("url", currentSettings.uploadUrl);
|
||||
prefs.putBool("ena_upload", currentSettings.enableUpload);
|
||||
prefs.end();
|
||||
}
|
||||
|
||||
void SettingsService::setupRoutes()
|
||||
{
|
||||
server.on("/", HTTP_GET, [this]()
|
||||
{
|
||||
String html = index_html;
|
||||
// Simple template replacement
|
||||
html.replace("%CONTRAST%", String(currentSettings.contrast));
|
||||
html.replace("%BRIGHTNESS%", String(currentSettings.brightness));
|
||||
html.replace("%VFLIP%", currentSettings.vFlip ? "checked" : "");
|
||||
html.replace("%HMIRROR%", currentSettings.hMirror ? "checked" : "");
|
||||
html.replace("%HEAT%", String(currentSettings.heatTime));
|
||||
html.replace("%URL%", currentSettings.uploadUrl);
|
||||
html.replace("%UPLOAD%", currentSettings.enableUpload ? "checked" : "");
|
||||
server.send(200, "text/html", html); });
|
||||
|
||||
server.on("/save", HTTP_POST, [this]()
|
||||
{
|
||||
if (server.hasArg("contrast")) currentSettings.contrast = server.arg("contrast").toInt();
|
||||
if (server.hasArg("brightness")) currentSettings.brightness = server.arg("brightness").toInt();
|
||||
if (server.hasArg("heat")) currentSettings.heatTime = server.arg("heat").toInt();
|
||||
currentSettings.uploadUrl = server.arg("url");
|
||||
|
||||
currentSettings.vFlip = server.hasArg("vflip");
|
||||
currentSettings.hMirror = server.hasArg("hmirror");
|
||||
currentSettings.enableUpload = server.hasArg("upload");
|
||||
|
||||
save();
|
||||
server.send(200, "text/html", "Settings Saved! <a href='/'>Go Back</a>");
|
||||
|
||||
// Apply immediate camera changes if needed
|
||||
sensor_t *s = esp_camera_sensor_get();
|
||||
if (s) {
|
||||
s->set_contrast(s, currentSettings.contrast);
|
||||
s->set_brightness(s, currentSettings.brightness);
|
||||
s->set_vflip(s, currentSettings.vFlip ? 1 : 0);
|
||||
s->set_hmirror(s, currentSettings.hMirror ? 1 : 0);
|
||||
} });
|
||||
}
|
||||
|
||||
void SettingsService::handle()
|
||||
{
|
||||
server.handleClient();
|
||||
}
|
||||
11
test/README
Normal file
11
test/README
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
This directory is intended for PlatformIO Test Runner and project tests.
|
||||
|
||||
Unit Testing is a software testing method by which individual units of
|
||||
source code, sets of one or more MCU program modules together with associated
|
||||
control data, usage procedures, and operating procedures, are tested to
|
||||
determine whether they are fit for use. Unit testing finds problems early
|
||||
in the development cycle.
|
||||
|
||||
More information about PlatformIO Unit Testing:
|
||||
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
||||
Reference in New Issue
Block a user