This commit is contained in:
2026-01-12 00:53:54 +01:00
commit 2f827c168d
26 changed files with 3145 additions and 0 deletions

5
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
};

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
/target
/uploads
.env

1901
server/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
server/Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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