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
|
||||
|
||||
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.
|
||||
1. **Frame your shot.** (Wide-angle lens — back up a little!)
|
||||
2. **Press the button.**
|
||||
3. **Wait ~5 seconds.**
|
||||
- A thermal print comes out of the printer.
|
||||
- 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?
|
||||
|
||||
1. Connect to the WiFi: **[Your_WiFi_Name]**
|
||||
2. Scan this code or go to:
|
||||
**http://[YOUR_LAPTOP_IP]:3000/gallery**
|
||||
3. Password: **partytime**
|
||||
1. Connect to the WiFi: **[FILL IN: venue WiFi name]**
|
||||
2. Go to: **http://[FILL IN: server IP]:3000/gallery**
|
||||
3. Password: **[FILL IN: gallery password]**
|
||||
|
||||
_(Ask the host if you can't connect!)_
|
||||
|
||||
---
|
||||
|
||||
> **Host checklist before printing this:** fill in the three `[FILL IN]` fields above.
|
||||
|
||||
169
README.md
169
README.md
@@ -1,82 +1,131 @@
|
||||
# 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
|
||||
|
||||
- **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)
|
||||
| Part | Notes |
|
||||
| :--- | :--- |
|
||||
| ESP32-CAM (AI-Thinker) | Camera + WiFi module |
|
||||
| Thermal Printer | TTL serial, 5V–9V |
|
||||
| 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
|
||||
|
||||
### 1. Camera & Printer
|
||||
| ESP32-CAM Pin | Component | Function |
|
||||
| :--- | :--- | :--- |
|
||||
| **5V** | PSU 5V | Power in |
|
||||
| **GND** | PSU GND | Common ground |
|
||||
| **GPIO 12** | Button | Shutter (other leg → 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 |
|
||||
|
||||
| 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._
|
||||
All pin assignments are in `include/config.h`. Change them there if your wiring differs.
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
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._
|
||||
```bash
|
||||
cd server
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 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.
|
||||
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.
|
||||
### Option B: Cargo (dev)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
Requires a `.env` file in the `server/` directory:
|
||||
|
||||
```ini
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=3000
|
||||
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.
|
||||
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.**
|
||||
### 1. Configure
|
||||
|
||||
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>
|
||||
|
||||
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) ...
|
||||
|
||||
// Camera Pins (ESP32-CAM AI-Thinker)
|
||||
constexpr int PIN_CAM_PWDN = 32;
|
||||
constexpr int PIN_CAM_RESET = -1;
|
||||
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
|
||||
constexpr int PIN_PRINTER_RX = 14;
|
||||
constexpr int PIN_PRINTER_RX = 14;
|
||||
constexpr int PIN_PRINTER_TX = 15;
|
||||
|
||||
|
||||
// Button
|
||||
constexpr int PIN_BUTTON = 12;
|
||||
|
||||
// Settings
|
||||
constexpr int PRINTER_WIDTH = 384;
|
||||
constexpr int BAUD_RATE = 9600; // Check your printer specs (some are 19200)
|
||||
constexpr int PRINTER_SERIAL = 1;
|
||||
}
|
||||
@@ -7,5 +7,5 @@ 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);
|
||||
static void uploadImage(const uint8_t *bitmap, size_t size, const String &url);
|
||||
};
|
||||
@@ -24,7 +24,7 @@ public:
|
||||
void sleep();
|
||||
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 printText(const String &text);
|
||||
|
||||
@@ -79,19 +79,32 @@
|
||||
}
|
||||
|
||||
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);
|
||||
const url = `/gallery/img/${encodeURIComponent(filename)}`;
|
||||
const label = filename
|
||||
.replace("capture_", "")
|
||||
.replace(".png", "")
|
||||
.replace(/_/g, " ");
|
||||
|
||||
const a = document.createElement("a");
|
||||
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
|
||||
|
||||
@@ -34,6 +34,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
PathBuf::from(env::var("UPLOAD_DIR").unwrap_or_else(|_| "./uploads".to_string()));
|
||||
|
||||
tokio::fs::create_dir_all(&upload_dir).await?;
|
||||
let upload_dir = upload_dir.canonicalize()?;
|
||||
let state = Arc::new(AppState {
|
||||
upload_dir,
|
||||
password,
|
||||
@@ -89,7 +90,13 @@ async fn gallery_view() -> Html<&'static str> {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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));
|
||||
axum::Json(images)
|
||||
axum::Json(images).into_response()
|
||||
}
|
||||
|
||||
async fn serve_image(
|
||||
@@ -112,7 +119,13 @@ async fn serve_image(
|
||||
Path(filename): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
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(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
@@ -150,10 +163,13 @@ async fn basic_auth(
|
||||
.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<()> {
|
||||
// Dimensions from ESP32 code (240x320)
|
||||
let width = 240;
|
||||
let height = 320;
|
||||
let width = IMAGE_WIDTH;
|
||||
let height = IMAGE_HEIGHT;
|
||||
|
||||
let mut img = GrayImage::new(width, height);
|
||||
|
||||
|
||||
@@ -2,46 +2,28 @@
|
||||
#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.pin_d0 = Config::PIN_CAM_Y2;
|
||||
config.pin_d1 = Config::PIN_CAM_Y3;
|
||||
config.pin_d2 = Config::PIN_CAM_Y4;
|
||||
config.pin_d3 = Config::PIN_CAM_Y5;
|
||||
config.pin_d4 = Config::PIN_CAM_Y6;
|
||||
config.pin_d5 = Config::PIN_CAM_Y7;
|
||||
config.pin_d6 = Config::PIN_CAM_Y8;
|
||||
config.pin_d7 = Config::PIN_CAM_Y9;
|
||||
config.pin_xclk = Config::PIN_CAM_XCLK;
|
||||
config.pin_pclk = Config::PIN_CAM_PCLK;
|
||||
config.pin_vsync = Config::PIN_CAM_VSYNC;
|
||||
config.pin_href = Config::PIN_CAM_HREF;
|
||||
config.pin_sscb_sda = Config::PIN_CAM_SIOD;
|
||||
config.pin_sscb_scl = Config::PIN_CAM_SIOC;
|
||||
config.pin_pwdn = Config::PIN_CAM_PWDN;
|
||||
config.pin_reset = Config::PIN_CAM_RESET;
|
||||
|
||||
config.xclk_freq_hz = 20000000;
|
||||
|
||||
|
||||
@@ -7,113 +7,86 @@ void ImageProcessor::processAndPrint(const CameraService::Frame &frame, PrinterS
|
||||
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
|
||||
// Rotate 90° CW: source is 320×240 (landscape), output is 240×320 (portrait)
|
||||
// dest[row=y, col=x] = src[row = srcHeight-1-x, col = y]
|
||||
const int width = frame.getHeight(); // 240
|
||||
const int height = frame.getWidth(); // 320
|
||||
const int rowBytes = (width + 7) / 8; // 30
|
||||
|
||||
// 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);
|
||||
const uint8_t *src = frame.getData();
|
||||
const int srcWidth = frame.getWidth(); // 320
|
||||
const int srcHeight = frame.getHeight(); // 240
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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++)
|
||||
{
|
||||
// Load rotated pixels for this row, folding in accumulated error from previous row
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
int16_t oldPixel = pixels[y * width + x];
|
||||
int16_t newPixel = (oldPixel > 128) ? 255 : 0;
|
||||
cur_row[x] = (int16_t)src[(srcHeight - 1 - x) * srcWidth + y] + next_row[x];
|
||||
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;
|
||||
|
||||
// Distribute Error (Bit shifting is faster than division)
|
||||
// Right: 7/16
|
||||
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 (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
|
||||
next_row[x - 1] += (error * 3) >> 4;
|
||||
next_row[x] += (error * 5) >> 4;
|
||||
if (x + 1 < width)
|
||||
pixels[(y + 1) * width + (x + 1)] += (error * 1) >> 4;
|
||||
next_row[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++)
|
||||
{
|
||||
// Pack row into bitmap
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
if (pixels[y * width + x] == 0)
|
||||
{ // If black
|
||||
if (cur_row[x] == 0)
|
||||
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.printBitmap(width, height, bitmap);
|
||||
printer.feed(3);
|
||||
printer.sleep();
|
||||
|
||||
// 6. Cleanup (RAII handled elsewhere, but raw pointers need free)
|
||||
free(pixels);
|
||||
if (settings.enableUpload && settings.uploadUrl.length() > 0)
|
||||
uploadImage(bitmap, bitmapSize, settings.uploadUrl);
|
||||
|
||||
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)
|
||||
return;
|
||||
@@ -125,13 +98,9 @@ void ImageProcessor::uploadImage(const uint8_t *bitmap, int size, const String &
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#include <Arduino.h>
|
||||
#include "camera_service.h"
|
||||
#include "config.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;
|
||||
|
||||
@@ -28,7 +28,7 @@ void setup()
|
||||
Serial.begin(115200);
|
||||
Serial.println("Party Cam Starting...");
|
||||
|
||||
pinMode(BUTTON_PIN, INPUT_PULLUP);
|
||||
pinMode(Config::PIN_BUTTON, INPUT_PULLUP);
|
||||
|
||||
CameraService::init();
|
||||
PrinterService::init();
|
||||
@@ -45,7 +45,7 @@ void loop()
|
||||
|
||||
settingsService.handle();
|
||||
|
||||
if (digitalRead(BUTTON_PIN) == LOW)
|
||||
if (digitalRead(Config::PIN_BUTTON) == LOW)
|
||||
{
|
||||
if ((millis() - lastDebounceTime) > debounceDelay)
|
||||
{
|
||||
|
||||
@@ -4,8 +4,8 @@ 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);
|
||||
printerSerial = new HardwareSerial(Config::PRINTER_SERIAL);
|
||||
printer = new Adafruit_Thermal(printerSerial);
|
||||
}
|
||||
|
||||
void PrinterService::init()
|
||||
@@ -51,10 +51,8 @@ void PrinterService::printText(const String &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 heatInterval = 50;
|
||||
|
||||
printer->setHeatConfig(heatDots, heatTime, heatInterval);
|
||||
}
|
||||
@@ -123,9 +123,9 @@ void SettingsService::setupRoutes()
|
||||
|
||||
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();
|
||||
if (server.hasArg("contrast")) currentSettings.contrast = constrain(server.arg("contrast").toInt(), -2, 2);
|
||||
if (server.hasArg("brightness")) currentSettings.brightness = constrain(server.arg("brightness").toInt(), -2, 2);
|
||||
if (server.hasArg("heat")) currentSettings.heatTime = constrain(server.arg("heat").toInt(), 0, 255);
|
||||
currentSettings.uploadUrl = server.arg("url");
|
||||
|
||||
currentSettings.vFlip = server.hasArg("vflip");
|
||||
|
||||
Reference in New Issue
Block a user