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:
2026-06-18 11:23:05 +02:00
parent 2f827c168d
commit 4ec723ef40
12 changed files with 263 additions and 219 deletions

View File

@@ -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.

173
README.md
View File

@@ -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
- **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 ## Hardware BOM
### A. The Server (Rust) | Part | Notes |
| :--- | :--- |
| ESP32-CAM (AI-Thinker) | Camera + WiFi module |
| Thermal Printer | TTL serial, 5V9V |
| 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 |
The server receives images and hosts the gallery for guests. ---
## Wiring
| 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 |
All pin assignments are in `include/config.h`. Change them there if your wiring differs.
---
## Server Setup
Run the server on any machine on the same local network as the camera. It receives uploads and hosts the gallery.
### Option A: Docker Compose (recommended)
```bash
cd server
docker compose up -d
```
Images are saved to `server/party_images/` on the host. To change the gallery password, edit `GALLERY_PASSWORD` in `compose.yml`.
### Option B: Cargo (dev)
```bash
cd server
cargo run --release
```
Requires a `.env` file in the `server/` directory:
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 ```ini
SERVER_HOST=0.0.0.0 SERVER_HOST=0.0.0.0
SERVER_PORT=3000 SERVER_PORT=3000
GALLERY_PASSWORD=partytime GALLERY_PASSWORD=partytime
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
``` ```
3. **Run:**
```bash
cd server
cargo run --release
```
_Server will listen on port 3000._
### B. The Camera (Firmware) `GALLERY_PASSWORD` is required — the server panics at startup without it.
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) ## 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) |

View File

@@ -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;
} }

View File

@@ -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);
}; };

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -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;

View File

@@ -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();
} }

View File

@@ -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)
{ {

View File

@@ -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);
} }

View File

@@ -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");