fix bugs, harden server, strip-based dithering, update docs
Firmware:
- fix 90° rotation (was transpose)
- fix Adafruit_Thermal constructor (spurious DTR pin arg)
- wire up uploadImage; heatInterval now applied
- PSRAM-free strip dithering: 2-row buffers (~1KB) replace 153KB PSRAM alloc
- consolidate all pins into config.h; BUTTON_PIN → Config::PIN_BUTTON
- constrain contrast/brightness/heat in settings save handler
- uploadImage size param int → size_t
Server:
- canonicalize upload_dir at startup (fixes path traversal guard)
- path traversal guard in serve_image
- replace unwrap in gallery_data with error handling
- IMAGE_WIDTH/IMAGE_HEIGHT named constants
Gallery:
- innerHTML → createElement (XSS-safe)
- encodeURIComponent on image URLs
- replace("_"," ") → regex /_/g
Docs: rewrite README, clarify GUEST_MANUAL placeholders
This commit is contained in:
@@ -6,11 +6,11 @@ Snap a photo, get a receipt, and view the digital archive instantly.
|
|||||||
|
|
||||||
### How to use
|
### How to use
|
||||||
|
|
||||||
1. **Frame your shot.** (The lens is slightly wide-angle!)
|
1. **Frame your shot.** (Wide-angle lens — back up a little!)
|
||||||
2. **Press the Button.**
|
2. **Press the button.**
|
||||||
3. **Wait ~5 seconds.**
|
3. **Wait ~5 seconds.**
|
||||||
- **Print:** Collect your physical photo.
|
- A thermal print comes out of the printer.
|
||||||
- **Upload:** The photo is magically sent to the gallery.
|
- The photo is uploaded to the live gallery.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -18,11 +18,12 @@ Snap a photo, get a receipt, and view the digital archive instantly.
|
|||||||
|
|
||||||
Want to save the photo to your phone?
|
Want to save the photo to your phone?
|
||||||
|
|
||||||
1. Connect to the WiFi: **[Your_WiFi_Name]**
|
1. Connect to the WiFi: **[FILL IN: venue WiFi name]**
|
||||||
2. Scan this code or go to:
|
2. Go to: **http://[FILL IN: server IP]:3000/gallery**
|
||||||
**http://[YOUR_LAPTOP_IP]:3000/gallery**
|
3. Password: **[FILL IN: gallery password]**
|
||||||
3. Password: **partytime**
|
|
||||||
|
|
||||||
_(Ask the host if you can't connect!)_
|
_(Ask the host if you can't connect!)_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> **Host checklist before printing this:** fill in the three `[FILL IN]` fields above.
|
||||||
|
|||||||
167
README.md
167
README.md
@@ -1,82 +1,131 @@
|
|||||||
# Party Cam: Instant Thermal Camera & Digital Gallery
|
# 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.
|
A DIY point-and-shoot that prints dithered lo-fi photos on thermal receipt paper and simultaneously uploads captures to a local Rust web server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Hardware BOM
|
## Hardware BOM
|
||||||
|
|
||||||
- **ESP32-CAM**
|
| Part | Notes |
|
||||||
- **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)
|
| ESP32-CAM (AI-Thinker) | Camera + WiFi module |
|
||||||
- **Button** (Tactile switch)
|
| Thermal Printer | TTL serial, 5V–9V |
|
||||||
- **Programmer**: FTDI Adapter or Arduino Uno (for flashing)
|
| 2× 18650 Li-Ion | In series = 7.4V; powers printer directly + 5V buck converter for ESP32 |
|
||||||
|
| Tactile button | Shutter trigger |
|
||||||
|
| FTDI adapter or Arduino Uno | For flashing only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Wiring
|
## Wiring
|
||||||
|
|
||||||
### 1. Camera & Printer
|
|
||||||
|
|
||||||
| ESP32-CAM Pin | Component | Function |
|
| ESP32-CAM Pin | Component | Function |
|
||||||
| :---------------- | :--------- | :--------------------------------- |
|
| :--- | :--- | :--- |
|
||||||
| **5V** | PSU 5V | Power In |
|
| **5V** | PSU 5V | Power in |
|
||||||
| **GND** | PSU GND | Common Ground |
|
| **GND** | PSU GND | Common ground |
|
||||||
| **GPIO 12** | Button | Trigger (Connect other leg to GND) |
|
| **GPIO 12** | Button | Shutter (other leg → GND) |
|
||||||
| **GPIO 14** (U1T) | Printer RX | Data TO Printer |
|
| **GPIO 14** (U1T) | Printer RX | Data to printer |
|
||||||
| **GPIO 15** (U1R) | Printer TX | Data FROM Printer |
|
| **GPIO 15** (U1R) | Printer TX | Data from printer |
|
||||||
| **GPIO 4** | Flash LED | (Optional) Built-in Flash |
|
| **GPIO 4** | Flash LED | Optional built-in flash |
|
||||||
|
|
||||||
### 2. Flashing (Using Arduino Uno as Bridge)
|
All pin assignments are in `include/config.h`. Change them there if your wiring differs.
|
||||||
|
|
||||||
_See "How to Flash" section below._
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setup Guide
|
## Server Setup
|
||||||
|
|
||||||
### A. The Server (Rust)
|
Run the server on any machine on the same local network as the camera. It receives uploads and hosts the gallery.
|
||||||
|
|
||||||
The server receives images and hosts the gallery for guests.
|
### Option A: Docker Compose (recommended)
|
||||||
|
|
||||||
1. **Install Rust:** `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`
|
```bash
|
||||||
2. **Configure:**
|
cd server
|
||||||
Create a `.env` file in the `server` folder:
|
docker compose up -d
|
||||||
```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)
|
Images are saved to `server/party_images/` on the host. To change the gallery password, edit `GALLERY_PASSWORD` in `compose.yml`.
|
||||||
|
|
||||||
1. **Open Project:** Open this folder in VS Code with the PlatformIO extension.
|
### Option B: Cargo (dev)
|
||||||
2. **Flash Mode Wiring (Arduino Uno Method):**
|
|
||||||
- **Arduino RESET** -> **Arduino GND** (Bypasses Arduino chip).
|
```bash
|
||||||
- **Arduino Pin 0 (RX)** -> **ESP32 U0R** (Direct passthrough).
|
cd server
|
||||||
- **Arduino Pin 1 (TX)** -> **ESP32 U0T** (Direct passthrough).
|
cargo run --release
|
||||||
- **ESP32 IO0** -> **ESP32 GND** (Enables Download Mode).
|
```
|
||||||
3. **Upload:**
|
|
||||||
- Connect via USB.
|
Requires a `.env` file in the `server/` directory:
|
||||||
- Click "Upload".
|
|
||||||
- Press the **RST** button on ESP32 when "Connecting..." appears.
|
```ini
|
||||||
4. **Run Mode:**
|
SERVER_HOST=0.0.0.0
|
||||||
- **Disconnect IO0 from GND.**
|
SERVER_PORT=3000
|
||||||
- Press **RST** to boot.
|
GALLERY_PASSWORD=partytime
|
||||||
|
UPLOAD_DIR=./uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
`GALLERY_PASSWORD` is required — the server panics at startup without it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration (First Boot)
|
## Firmware Setup
|
||||||
|
|
||||||
1. **Power on** the camera.
|
### 1. Configure
|
||||||
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!
|
Check `platformio.ini` and make sure `board` matches your actual hardware. The default is `4d_systems_esp32s3_gen4_r8n16` — change it to `ai_thinker_esp32-cam` if using a standard ESP32-CAM. Camera pin definitions in `include/config.h` are pre-set for AI-Thinker.
|
||||||
|
|
||||||
|
### 2. Flash (Arduino Uno as bridge)
|
||||||
|
|
||||||
|
Wire the Uno as a passthrough:
|
||||||
|
- Arduino RESET → Arduino GND (bypasses the Uno chip)
|
||||||
|
- Arduino Pin 0 (RX) → ESP32 U0R
|
||||||
|
- Arduino Pin 1 (TX) → ESP32 U0T
|
||||||
|
- **ESP32 IO0 → ESP32 GND** (enables download mode)
|
||||||
|
|
||||||
|
Click **Upload** in PlatformIO, then press RST on the ESP32 when "Connecting…" appears.
|
||||||
|
|
||||||
|
### 3. Boot
|
||||||
|
|
||||||
|
Disconnect IO0 from GND, press RST. The camera boots.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First Boot Configuration
|
||||||
|
|
||||||
|
1. Power on. Camera broadcasts WiFi AP: **`PartyCam-Setup`**.
|
||||||
|
2. Connect your phone — a captive portal opens automatically (or go to `192.168.4.1`).
|
||||||
|
3. Select the venue WiFi and enter the password.
|
||||||
|
4. Set the **Upload URL** to `http://<server-machine-ip>:3000/upload`.
|
||||||
|
5. Adjust contrast/heat to taste. **Save & Reboot.**
|
||||||
|
|
||||||
|
The portal times out after 3 minutes. If it closes before you save, the camera boots in offline mode (printing still works, upload disabled).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings Page
|
||||||
|
|
||||||
|
After first boot the settings page is always reachable at:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://partycam.local
|
||||||
|
```
|
||||||
|
|
||||||
|
(or the camera's IP address on port 80). Use it to adjust contrast, brightness, flip/mirror, heat density, and the upload URL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gallery
|
||||||
|
|
||||||
|
```
|
||||||
|
http://<server-ip>:3000/gallery
|
||||||
|
```
|
||||||
|
|
||||||
|
Open from any device on the same network. The browser will prompt for the gallery password. New photos appear automatically — the page polls every 5 seconds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customisation
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
| :--- | :--- |
|
||||||
|
| Pin assignments | `include/config.h` |
|
||||||
|
| Printer baud rate | `include/config.h` → `BAUD_RATE` |
|
||||||
|
| Default camera settings | `include/settings_service.h` → `AppSettings` struct defaults |
|
||||||
|
| Gallery password | `compose.yml` or `.env` |
|
||||||
|
| Capture resolution | `src/camera_service.cpp` → `config.frame_size` (update `IMAGE_WIDTH`/`IMAGE_HEIGHT` in `server/src/main.rs` to match) |
|
||||||
|
|||||||
@@ -2,16 +2,32 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
|
||||||
namespace Config {
|
namespace Config {
|
||||||
// Hardware Pins (ESP32-CAM AI-Thinker)
|
// Camera Pins (ESP32-CAM AI-Thinker)
|
||||||
constexpr int PIN_CAM_PWDN = 32;
|
constexpr int PIN_CAM_PWDN = 32;
|
||||||
constexpr int PIN_CAM_RESET = -1;
|
constexpr int PIN_CAM_RESET = -1;
|
||||||
// ... (Add standard camera pins here to keep main clean) ...
|
constexpr int PIN_CAM_XCLK = 0;
|
||||||
|
constexpr int PIN_CAM_SIOD = 26;
|
||||||
|
constexpr int PIN_CAM_SIOC = 27;
|
||||||
|
constexpr int PIN_CAM_Y9 = 35;
|
||||||
|
constexpr int PIN_CAM_Y8 = 34;
|
||||||
|
constexpr int PIN_CAM_Y7 = 39;
|
||||||
|
constexpr int PIN_CAM_Y6 = 36;
|
||||||
|
constexpr int PIN_CAM_Y5 = 21;
|
||||||
|
constexpr int PIN_CAM_Y4 = 19;
|
||||||
|
constexpr int PIN_CAM_Y3 = 18;
|
||||||
|
constexpr int PIN_CAM_Y2 = 5;
|
||||||
|
constexpr int PIN_CAM_VSYNC = 25;
|
||||||
|
constexpr int PIN_CAM_HREF = 23;
|
||||||
|
constexpr int PIN_CAM_PCLK = 22;
|
||||||
|
|
||||||
// Printer Pins
|
// Printer Pins
|
||||||
constexpr int PIN_PRINTER_RX = 14;
|
constexpr int PIN_PRINTER_RX = 14;
|
||||||
constexpr int PIN_PRINTER_TX = 15;
|
constexpr int PIN_PRINTER_TX = 15;
|
||||||
|
|
||||||
|
// Button
|
||||||
|
constexpr int PIN_BUTTON = 12;
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
constexpr int PRINTER_WIDTH = 384;
|
|
||||||
constexpr int BAUD_RATE = 9600; // Check your printer specs (some are 19200)
|
constexpr int BAUD_RATE = 9600; // Check your printer specs (some are 19200)
|
||||||
|
constexpr int PRINTER_SERIAL = 1;
|
||||||
}
|
}
|
||||||
@@ -7,5 +7,5 @@ class ImageProcessor
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
static void processAndPrint(const CameraService::Frame &frame, PrinterService &printer, const AppSettings &settings);
|
static void processAndPrint(const CameraService::Frame &frame, PrinterService &printer, const AppSettings &settings);
|
||||||
static void uploadImage(const uint8_t *bitmap, int size, const String &url);
|
static void uploadImage(const uint8_t *bitmap, size_t size, const String &url);
|
||||||
};
|
};
|
||||||
@@ -24,7 +24,7 @@ public:
|
|||||||
void sleep();
|
void sleep();
|
||||||
void feed(int lines);
|
void feed(int lines);
|
||||||
|
|
||||||
void setHeat(uint8_t heatTime);
|
void setHeat(uint8_t heatTime, uint8_t heatInterval);
|
||||||
|
|
||||||
void printBitmap(int w, int h, const uint8_t *bitmap);
|
void printBitmap(int w, int h, const uint8_t *bitmap);
|
||||||
void printText(const String &text);
|
void printText(const String &text);
|
||||||
|
|||||||
@@ -79,19 +79,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addCard(filename) {
|
function addCard(filename) {
|
||||||
const div = document.createElement("div");
|
const url = `/gallery/img/${encodeURIComponent(filename)}`;
|
||||||
div.className = "card";
|
const label = filename
|
||||||
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("capture_", "")
|
||||||
.replace(".png", "")
|
.replace(".png", "")
|
||||||
.replace("_", " ")}</div>
|
.replace(/_/g, " ");
|
||||||
`;
|
|
||||||
// Prepend to show new ones at top
|
const a = document.createElement("a");
|
||||||
gallery.prepend(div);
|
a.href = url;
|
||||||
|
a.target = "_blank";
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = url;
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.alt = "Party Photo";
|
||||||
|
a.appendChild(img);
|
||||||
|
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "meta";
|
||||||
|
meta.textContent = label;
|
||||||
|
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "card";
|
||||||
|
card.appendChild(a);
|
||||||
|
card.appendChild(meta);
|
||||||
|
|
||||||
|
gallery.prepend(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial load and poll every 5 seconds
|
// Initial load and poll every 5 seconds
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
PathBuf::from(env::var("UPLOAD_DIR").unwrap_or_else(|_| "./uploads".to_string()));
|
PathBuf::from(env::var("UPLOAD_DIR").unwrap_or_else(|_| "./uploads".to_string()));
|
||||||
|
|
||||||
tokio::fs::create_dir_all(&upload_dir).await?;
|
tokio::fs::create_dir_all(&upload_dir).await?;
|
||||||
|
let upload_dir = upload_dir.canonicalize()?;
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
upload_dir,
|
upload_dir,
|
||||||
password,
|
password,
|
||||||
@@ -89,7 +90,13 @@ async fn gallery_view() -> Html<&'static str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn gallery_data(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
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 entries = match tokio::fs::read_dir(&state.upload_dir).await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to read upload dir: {}", e);
|
||||||
|
return axum::Json(Vec::<String>::new()).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut images = Vec::new();
|
let mut images = Vec::new();
|
||||||
|
|
||||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
@@ -104,7 +111,7 @@ async fn gallery_data(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
images.sort_by(|a, b| b.cmp(a));
|
images.sort_by(|a, b| b.cmp(a));
|
||||||
axum::Json(images)
|
axum::Json(images).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_image(
|
async fn serve_image(
|
||||||
@@ -112,7 +119,13 @@ async fn serve_image(
|
|||||||
Path(filename): Path<String>,
|
Path(filename): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let path = state.upload_dir.join(&filename);
|
let path = state.upload_dir.join(&filename);
|
||||||
match tokio::fs::read(&path).await {
|
let Ok(canonical) = path.canonicalize() else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
if !canonical.starts_with(&state.upload_dir) {
|
||||||
|
return StatusCode::BAD_REQUEST.into_response();
|
||||||
|
}
|
||||||
|
match tokio::fs::read(&canonical).await {
|
||||||
Ok(bytes) => ([(header::CONTENT_TYPE, "image/png")], bytes).into_response(),
|
Ok(bytes) => ([(header::CONTENT_TYPE, "image/png")], bytes).into_response(),
|
||||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
}
|
}
|
||||||
@@ -150,10 +163,13 @@ async fn basic_auth(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Must match FRAMESIZE_QVGA rotated 90°: camera captures 320×240, firmware rotates to 240×320.
|
||||||
|
const IMAGE_WIDTH: u32 = 240;
|
||||||
|
const IMAGE_HEIGHT: u32 = 320;
|
||||||
|
|
||||||
fn convert_and_save(data: &[u8], path: &PathBuf) -> anyhow::Result<()> {
|
fn convert_and_save(data: &[u8], path: &PathBuf) -> anyhow::Result<()> {
|
||||||
// Dimensions from ESP32 code (240x320)
|
let width = IMAGE_WIDTH;
|
||||||
let width = 240;
|
let height = IMAGE_HEIGHT;
|
||||||
let height = 320;
|
|
||||||
|
|
||||||
let mut img = GrayImage::new(width, height);
|
let mut img = GrayImage::new(width, height);
|
||||||
|
|
||||||
|
|||||||
@@ -2,46 +2,28 @@
|
|||||||
#include "camera_service.h"
|
#include "camera_service.h"
|
||||||
#include "config.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()
|
void CameraService::init()
|
||||||
{
|
{
|
||||||
camera_config_t config;
|
camera_config_t config;
|
||||||
|
|
||||||
config.ledc_channel = LEDC_CHANNEL_0;
|
config.ledc_channel = LEDC_CHANNEL_0;
|
||||||
config.ledc_timer = LEDC_TIMER_0;
|
config.ledc_timer = LEDC_TIMER_0;
|
||||||
config.pin_d0 = Y2_GPIO_NUM;
|
config.pin_d0 = Config::PIN_CAM_Y2;
|
||||||
config.pin_d1 = Y3_GPIO_NUM;
|
config.pin_d1 = Config::PIN_CAM_Y3;
|
||||||
config.pin_d2 = Y4_GPIO_NUM;
|
config.pin_d2 = Config::PIN_CAM_Y4;
|
||||||
config.pin_d3 = Y5_GPIO_NUM;
|
config.pin_d3 = Config::PIN_CAM_Y5;
|
||||||
config.pin_d4 = Y6_GPIO_NUM;
|
config.pin_d4 = Config::PIN_CAM_Y6;
|
||||||
config.pin_d5 = Y7_GPIO_NUM;
|
config.pin_d5 = Config::PIN_CAM_Y7;
|
||||||
config.pin_d6 = Y8_GPIO_NUM;
|
config.pin_d6 = Config::PIN_CAM_Y8;
|
||||||
config.pin_d7 = Y9_GPIO_NUM;
|
config.pin_d7 = Config::PIN_CAM_Y9;
|
||||||
config.pin_xclk = XCLK_GPIO_NUM;
|
config.pin_xclk = Config::PIN_CAM_XCLK;
|
||||||
config.pin_pclk = PCLK_GPIO_NUM;
|
config.pin_pclk = Config::PIN_CAM_PCLK;
|
||||||
config.pin_vsync = VSYNC_GPIO_NUM;
|
config.pin_vsync = Config::PIN_CAM_VSYNC;
|
||||||
config.pin_href = HREF_GPIO_NUM;
|
config.pin_href = Config::PIN_CAM_HREF;
|
||||||
config.pin_sscb_sda = SIOD_GPIO_NUM;
|
config.pin_sscb_sda = Config::PIN_CAM_SIOD;
|
||||||
config.pin_sscb_scl = SIOC_GPIO_NUM;
|
config.pin_sscb_scl = Config::PIN_CAM_SIOC;
|
||||||
config.pin_pwdn = PWDN_GPIO_NUM;
|
config.pin_pwdn = Config::PIN_CAM_PWDN;
|
||||||
config.pin_reset = RESET_GPIO_NUM;
|
config.pin_reset = Config::PIN_CAM_RESET;
|
||||||
|
|
||||||
config.xclk_freq_hz = 20000000;
|
config.xclk_freq_hz = 20000000;
|
||||||
|
|
||||||
|
|||||||
@@ -7,113 +7,86 @@ void ImageProcessor::processAndPrint(const CameraService::Frame &frame, PrinterS
|
|||||||
if (!frame.isValid())
|
if (!frame.isValid())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// 1. Setup Dimensions (Rotate 90 degrees)
|
// Rotate 90° CW: source is 320×240 (landscape), output is 240×320 (portrait)
|
||||||
// Camera is 320x240 (Landscape) -> Printer is 384 wide (Portrait)
|
// dest[row=y, col=x] = src[row = srcHeight-1-x, col = y]
|
||||||
// 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 width = frame.getHeight(); // 240
|
||||||
const int height = frame.getWidth(); // 320
|
const int height = frame.getWidth(); // 320
|
||||||
|
const int rowBytes = (width + 7) / 8; // 30
|
||||||
|
|
||||||
// 2. Allocate Buffer in PSRAM
|
const uint8_t *src = frame.getData();
|
||||||
// We need int16_t to handle the overflow error during calculation
|
const int srcWidth = frame.getWidth(); // 320
|
||||||
size_t bufSize = width * height * sizeof(int16_t);
|
const int srcHeight = frame.getHeight(); // 240
|
||||||
int16_t *pixels = (int16_t *)heap_caps_malloc(bufSize, MALLOC_CAP_SPIRAM);
|
|
||||||
|
|
||||||
if (!pixels)
|
// Two row buffers for Floyd-Steinberg: current row + next-row error accumulator.
|
||||||
|
// ~960 bytes total — fits in internal RAM, no PSRAM required.
|
||||||
|
int16_t *cur_row = (int16_t *)malloc(width * sizeof(int16_t));
|
||||||
|
int16_t *next_row = (int16_t *)calloc(width, sizeof(int16_t));
|
||||||
|
size_t bitmapSize = (size_t)rowBytes * height;
|
||||||
|
uint8_t *bitmap = (uint8_t *)calloc(bitmapSize, 1);
|
||||||
|
|
||||||
|
if (!cur_row || !next_row || !bitmap)
|
||||||
{
|
{
|
||||||
Serial.println("[ERR] PSRAM Alloc Failed");
|
free(cur_row);
|
||||||
|
free(next_row);
|
||||||
|
free(bitmap);
|
||||||
|
Serial.println("[ERR] Alloc Failed");
|
||||||
return;
|
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 y = 0; y < height; y++)
|
||||||
{
|
{
|
||||||
|
// Load rotated pixels for this row, folding in accumulated error from previous row
|
||||||
for (int x = 0; x < width; x++)
|
for (int x = 0; x < width; x++)
|
||||||
{
|
{
|
||||||
int16_t oldPixel = pixels[y * width + x];
|
cur_row[x] = (int16_t)src[(srcHeight - 1 - x) * srcWidth + y] + next_row[x];
|
||||||
int16_t newPixel = (oldPixel > 128) ? 255 : 0;
|
next_row[x] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
pixels[y * width + x] = newPixel;
|
// Floyd-Steinberg dithering
|
||||||
|
for (int x = 0; x < width; x++)
|
||||||
|
{
|
||||||
|
int16_t oldPixel = cur_row[x];
|
||||||
|
int16_t newPixel = (oldPixel > 128) ? 255 : 0;
|
||||||
|
cur_row[x] = newPixel;
|
||||||
int16_t error = oldPixel - newPixel;
|
int16_t error = oldPixel - newPixel;
|
||||||
|
|
||||||
// Distribute Error (Bit shifting is faster than division)
|
|
||||||
// Right: 7/16
|
|
||||||
if (x + 1 < width)
|
if (x + 1 < width)
|
||||||
pixels[y * width + (x + 1)] += (error * 7) >> 4;
|
cur_row[x + 1] += (error * 7) >> 4;
|
||||||
|
|
||||||
// Bottom-Left: 3/16
|
|
||||||
if (y + 1 < height)
|
if (y + 1 < height)
|
||||||
{
|
{
|
||||||
if (x - 1 >= 0)
|
if (x - 1 >= 0)
|
||||||
pixels[(y + 1) * width + (x - 1)] += (error * 3) >> 4;
|
next_row[x - 1] += (error * 3) >> 4;
|
||||||
|
next_row[x] += (error * 5) >> 4;
|
||||||
// Bottom: 5/16
|
|
||||||
pixels[(y + 1) * width + x] += (error * 5) >> 4;
|
|
||||||
|
|
||||||
// Bottom-Right: 1/16
|
|
||||||
if (x + 1 < width)
|
if (x + 1 < width)
|
||||||
pixels[(y + 1) * width + (x + 1)] += (error * 1) >> 4;
|
next_row[x + 1] += (error * 1) >> 4;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Pack & Print
|
// Pack row into bitmap
|
||||||
// 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++)
|
for (int x = 0; x < width; x++)
|
||||||
{
|
{
|
||||||
if (pixels[y * width + x] == 0)
|
if (cur_row[x] == 0)
|
||||||
{ // If black
|
|
||||||
bitmap[y * rowBytes + (x / 8)] |= (1 << (7 - (x % 8)));
|
bitmap[y * rowBytes + (x / 8)] |= (1 << (7 - (x % 8)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
printer.setHeat(settings.heatTime);
|
free(cur_row);
|
||||||
|
free(next_row);
|
||||||
|
|
||||||
|
printer.setHeat(settings.heatTime, settings.heatInterval);
|
||||||
printer.wake();
|
printer.wake();
|
||||||
printer.printBitmap(width, height, bitmap);
|
printer.printBitmap(width, height, bitmap);
|
||||||
printer.feed(3);
|
printer.feed(3);
|
||||||
printer.sleep();
|
printer.sleep();
|
||||||
|
|
||||||
// 6. Cleanup (RAII handled elsewhere, but raw pointers need free)
|
if (settings.enableUpload && settings.uploadUrl.length() > 0)
|
||||||
free(pixels);
|
uploadImage(bitmap, bitmapSize, settings.uploadUrl);
|
||||||
|
|
||||||
free(bitmap);
|
free(bitmap);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ImageProcessor::uploadImage(const uint8_t *bitmap, int size, const String &url)
|
void ImageProcessor::uploadImage(const uint8_t *bitmap, size_t size, const String &url)
|
||||||
{
|
{
|
||||||
if (WiFi.status() != WL_CONNECTED)
|
if (WiFi.status() != WL_CONNECTED)
|
||||||
return;
|
return;
|
||||||
@@ -125,13 +98,9 @@ void ImageProcessor::uploadImage(const uint8_t *bitmap, int size, const String &
|
|||||||
int responseCode = http.POST((uint8_t *)bitmap, size);
|
int responseCode = http.POST((uint8_t *)bitmap, size);
|
||||||
|
|
||||||
if (responseCode > 0)
|
if (responseCode > 0)
|
||||||
{
|
|
||||||
Serial.printf("[Upload] Success: %d\n", responseCode);
|
Serial.printf("[Upload] Success: %d\n", responseCode);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
Serial.printf("[Upload] Failed: %s\n", http.errorToString(responseCode).c_str());
|
Serial.printf("[Upload] Failed: %s\n", http.errorToString(responseCode).c_str());
|
||||||
}
|
|
||||||
|
|
||||||
http.end();
|
http.end();
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include "camera_service.h"
|
#include "camera_service.h"
|
||||||
|
#include "config.h"
|
||||||
#include "printer_service.h"
|
#include "printer_service.h"
|
||||||
#include "image_processor.h"
|
#include "image_processor.h"
|
||||||
#include "settings_service.h"
|
#include "settings_service.h"
|
||||||
|
|
||||||
const int BUTTON_PIN = 12; // Check your wiring
|
|
||||||
unsigned long lastDebounceTime = 0;
|
unsigned long lastDebounceTime = 0;
|
||||||
const unsigned long debounceDelay = 200;
|
const unsigned long debounceDelay = 200;
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ void setup()
|
|||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
Serial.println("Party Cam Starting...");
|
Serial.println("Party Cam Starting...");
|
||||||
|
|
||||||
pinMode(BUTTON_PIN, INPUT_PULLUP);
|
pinMode(Config::PIN_BUTTON, INPUT_PULLUP);
|
||||||
|
|
||||||
CameraService::init();
|
CameraService::init();
|
||||||
PrinterService::init();
|
PrinterService::init();
|
||||||
@@ -45,7 +45,7 @@ void loop()
|
|||||||
|
|
||||||
settingsService.handle();
|
settingsService.handle();
|
||||||
|
|
||||||
if (digitalRead(BUTTON_PIN) == LOW)
|
if (digitalRead(Config::PIN_BUTTON) == LOW)
|
||||||
{
|
{
|
||||||
if ((millis() - lastDebounceTime) > debounceDelay)
|
if ((millis() - lastDebounceTime) > debounceDelay)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ PrinterService *PrinterService::instance = nullptr;
|
|||||||
|
|
||||||
PrinterService::PrinterService()
|
PrinterService::PrinterService()
|
||||||
{
|
{
|
||||||
printerSerial = new HardwareSerial(1); // replace it with not a magic number
|
printerSerial = new HardwareSerial(Config::PRINTER_SERIAL);
|
||||||
printer = new Adafruit_Thermal(printerSerial, Config::PIN_PRINTER_RX);
|
printer = new Adafruit_Thermal(printerSerial);
|
||||||
}
|
}
|
||||||
|
|
||||||
void PrinterService::init()
|
void PrinterService::init()
|
||||||
@@ -51,10 +51,8 @@ void PrinterService::printText(const String &text)
|
|||||||
printer->println(text);
|
printer->println(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
void PrinterService::setHeat(uint8_t heatTime)
|
void PrinterService::setHeat(uint8_t heatTime, uint8_t heatInterval)
|
||||||
{
|
{
|
||||||
constexpr int heatDots = 11;
|
constexpr int heatDots = 11;
|
||||||
constexpr int heatInterval = 50;
|
|
||||||
|
|
||||||
printer->setHeatConfig(heatDots, heatTime, heatInterval);
|
printer->setHeatConfig(heatDots, heatTime, heatInterval);
|
||||||
}
|
}
|
||||||
@@ -123,9 +123,9 @@ void SettingsService::setupRoutes()
|
|||||||
|
|
||||||
server.on("/save", HTTP_POST, [this]()
|
server.on("/save", HTTP_POST, [this]()
|
||||||
{
|
{
|
||||||
if (server.hasArg("contrast")) currentSettings.contrast = server.arg("contrast").toInt();
|
if (server.hasArg("contrast")) currentSettings.contrast = constrain(server.arg("contrast").toInt(), -2, 2);
|
||||||
if (server.hasArg("brightness")) currentSettings.brightness = server.arg("brightness").toInt();
|
if (server.hasArg("brightness")) currentSettings.brightness = constrain(server.arg("brightness").toInt(), -2, 2);
|
||||||
if (server.hasArg("heat")) currentSettings.heatTime = server.arg("heat").toInt();
|
if (server.hasArg("heat")) currentSettings.heatTime = constrain(server.arg("heat").toInt(), 0, 255);
|
||||||
currentSettings.uploadUrl = server.arg("url");
|
currentSettings.uploadUrl = server.arg("url");
|
||||||
|
|
||||||
currentSettings.vFlip = server.hasArg("vflip");
|
currentSettings.vFlip = server.hasArg("vflip");
|
||||||
|
|||||||
Reference in New Issue
Block a user