🥤 Bài 10 — Hoàn thiện máy bán nước mini: ESP32 + Laravel + QR + Motor + Sensor
Sau 9 bài, chúng ta đã lần lượt xây dựng:Cơ cấu nhả lon.Motor và driver.ESP32.Encoder/Sensor.State Machine.Màn hình cảm ứng.Thanh toán QR.Backend Lara...
Dành cho những ai chưa biết về lập trình hay các bạn muốn chuyển sang nghề thiết kế web
Dành cho những ai chưa biết về lập trình hay các bạn muốn chuyển sang nghề thiết kế web
Sau 9 bài, chúng ta đã lần lượt xây dựng:
Cơ cấu nhả lon.
Motor và driver.
ESP32.
Encoder/Sensor.
State Machine.
Màn hình cảm ứng.
Thanh toán QR.
Backend Laravel.
Quản lý tồn kho.
Bây giờ chúng ta ghép tất cả lại thành một hệ thống vending machine mini hoàn chỉnh.
Mục tiêu của bài này không phải làm một chiếc máy thương mại ngay lập tức.
Mục tiêu là:
Làm cho một người có thể chọn một lon nước → thanh toán → máy tự động nhả đúng một lon → cảm biến xác nhận → hệ thống cập nhật tồn kho.
Hệ thống cuối cùng:
👤 KHÁCH HÀNG
│
▼
┌───────────────┐
│ TOUCH SCREEN │
└───────┬───────┘
│
Chọn sản phẩm
│
▼
┌───────────────┐
│ ESP32 │
└───────┬───────┘
│
HTTP / HTTPS
│
▼
┌───────────────┐
│ LARAVEL │
│ SERVER │
└───────┬───────┘
│
Tạo Order
│
▼
QR
│
▼
💳 Thanh toán
│
▼
Payment = PAID
│
▼
ESP32
│
▼
┌───────────────┐
│ MOTOR DRIVER │
└───────┬───────┘
│
▼
MOTOR
│
▼
TRỤC XOẮN
│
▼
🥤
│
▼
SENSOR
│
▼
Product Delivered
│
▼
Laravel
│
▼
Stock - 1
Đây chính là kiến trúc IoT vending machine cơ bản.
Để học, chúng ta không cần làm máy lớn.
Một phiên bản nhỏ có thể gồm:
ESP32
Màn hình cảm ứng
Motor DC hoặc Stepper
Motor Driver
IR Sensor
Encoder/Hall Sensor
Nguồn DC
Nút dừng khẩn cấp
Cơ cấu trục xoắn
Khung máy
Có thể bắt đầu với một ngăn duy nhất.
Ví dụ:
┌──────────────────────────┐
│ │
│ 🥤 🥤 🥤 🥤 │
│ 🥤 🥤 🥤 🥤 │
│ │
│ TRỤC XOẮN │
│ ════════════════════ │
│ │
│ ↓ │
│ SENSOR │
│ ↓ │
│ ┌────────┐ │
│ │ NHẬN │ │
│ │ HÀNG │ │
│ └────────┘ │
└──────────────────────────┘
Sau khi chạy ổn định mới nhân lên:
A1
A2
B1
B2
Đây là cách chúng ta tránh việc code trở thành một mớ hỗn độn.
Touch Screen
Nhiệm vụ:
Hiển thị sản phẩm.
Nhận thao tác.
Hiển thị QR.
Hiển thị trạng thái.
State Machine
Nhiệm vụ:
Quyết định máy đang ở trạng thái nào.
Chuyển trạng thái.
Kiểm soát timeout.
Xử lý lỗi.
Motor
Sensor
Encoder
Nhiệm vụ:
Chạy motor.
Dừng motor.
Đọc sensor.
Phát hiện sản phẩm.
Laravel
Database
Payment
Inventory
Admin
Nhiệm vụ:
Quản lý sản phẩm.
Quản lý giá.
Tạo Order.
Xác nhận thanh toán.
Quản lý tồn kho.
Ghi log.
Quản lý máy.
Đây là phần quan trọng nhất.
┌──────────┐
│ IDLE │
└────┬─────┘
↓
┌──────────┐
│ SELECT │
└────┬─────┘
↓
┌───────────────┐
│ CREATE_ORDER │
└───────┬───────┘
↓
┌───────────────┐
│ SHOW_QR │
└───────┬───────┘
↓
┌─────────────────┐
│ WAIT_PAYMENT │
└────────┬────────┘
│
┌───────┴───────┐
↓ ↓
TIMEOUT PAID
↓ ↓
IDLE DISPENSE
↓
CHECK
↙ ↘
SUCCESS ERROR
↓ ↓
IDLE ERROR
Toàn bộ máy sẽ xoay quanh State Machine này.
IDLEKhi không có người sử dụng:
IDLE
Màn hình:
┌──────────────────────────────┐
│ │
│ 🥤 DRINK │
│ │
│ MÁY BÁN NƯỚC │
│ │
│ Chạm để bắt đầu │
│ │
└──────────────────────────────┘
Không có giao dịch nào đang chạy.
SELECTKhách chọn:
A1 — Coca
ESP32 lưu:
selectedSlot = "A1";
Nhưng chưa nhả hàng.
ESP32 hỏi Backend:
GET /api/machines/VM001/products/A1
Server trả:
{
"code": "A1",
"name": "Coca",
"price": 15000,
"stock": 5,
"status": "active"
}
Nếu:
stock > 0
thì tiếp tục.
Nếu:
stock = 0
hiển thị:
HẾT HÀNG
và quay lại:
SELECT
Nếu còn hàng:
A1
Coca
15.000đ
ESP32 gọi:
POST /api/orders
Server tạo:
Order #10001
Trạng thái:
payment_status = pending
delivery_status = pending
Server trả về thông tin giao dịch.
ESP32 hiển thị:
┌──────────────────────────────┐
│ COCA │
│ │
│ 15.000đ │
│ │
│ ┌───────────┐ │
│ │ │ │
│ │ QR │ │
│ │ │ │
│ └───────────┘ │
│ │
│ Quét để thanh toán │
│ │
└──────────────────────────────┘
State:
WAIT_PAYMENT
ESP32 định kỳ hỏi:
GET /api/orders/10001
Server trả:
{
"payment_status": "pending"
}
Tiếp tục chờ.
Khi:
{
"payment_status": "paid"
}
ESP32 chuyển:
WAIT_PAYMENT
↓
DISPENSE
Ví dụ:
120 giây
Nếu khách không thanh toán:
WAIT_PAYMENT
↓
TIMEOUT
↓
IDLE
Màn hình:
Giao dịch đã hết thời gian.
Vui lòng thử lại.
Khi thanh toán thành công:
DISPENSE
ESP32:
Motor ON
Motor quay:
↻
↻
↻
Trục xoắn đẩy lon về phía trước.
Nếu dùng encoder, ESP32 có thể biết motor đã quay bao nhiêu.
Ví dụ:
Target = 1000 pulses
Motor chạy:
0
100
200
300
...
900
1000
Khi:
pulse >= target
thì:
motorStop();
Encoder chỉ cho biết:
Motor đã quay đúng vị trí.
Nó không chắc chắn:
Lon đã thực sự rơi xuống hay chưa.
Ví dụ:
Motor
↓
Trục xoắn quay
↓
Lon bị kẹt
Encoder vẫn có thể báo:
OK
Vì vậy cần thêm:
Sensor xác nhận sản phẩm.
Ví dụ sử dụng IR Sensor:
🥤
↓
↓
┌─────────┐
│ IR │
│ SENSOR │
└────┬────┘
↓
PRODUCT_DETECTED
ESP32 chờ:
Sensor = DETECTED
Chúng ta cần:
Motor position = OK
AND
Product sensor = DETECTED
Khi cả hai đúng:
DELIVERY SUCCESS
Ví dụ:
Motor → OK
Sensor → NO PRODUCT
Sau khoảng:
5 giây
máy chuyển:
ERROR
Màn hình:
┌──────────────────────────────┐
│ ! │
│ │
│ KHÔNG THỂ GIAO HÀNG │
│ │
│ Vui lòng liên hệ │
│ nhân viên hỗ trợ. │
└──────────────────────────────┘
Đây là nguyên tắc rất quan trọng.
Nếu:
Payment = PAID
Delivery = FAILED
thì:
stock không được giảm
Ví dụ:
Stock = 5
Thanh toán
↓
Motor lỗi
↓
Sensor không phát hiện
Stock vẫn = 5
Nhưng Order phải được lưu:
payment_status = paid
delivery_status = failed
Để hệ thống xử lý tiếp.
Nếu:
Sensor = DETECTED
thì:
delivery_status = success
Sau đó:
stock = stock - 1
và ghi:
InventoryLog
Ví dụ:
A1
SALE
-1
Order #10001
Màn hình:
┌──────────────────────────────┐
│ │
│ ✓ │
│ │
│ GIAO HÀNG THÀNH CÔNG │
│ │
│ Vui lòng lấy hàng │
│ │
└──────────────────────────────┘
Sau vài giây:
SUCCESS
↓
IDLE
Kiến trúc API tối thiểu:
/api
│
├── machines
│
├── products
│
├── orders
│
├── payments
│
└── inventory
Ví dụ:
POST /api/orders
GET /api/orders/{id}
GET /api/machines/{code}
GET /api/machines/{code}/products
POST /api/machines/{code}/events
Ví dụ:
public function store(Request $request)
{
$machine = Machine::where(
'code',
$request->machine_code
)->firstOrFail();
$machineProduct = MachineProduct::where(
'machine_id',
$machine->id
)
->where('slot', $request->slot)
->firstOrFail();
if ($machineProduct->stock <= 0) {
return response()->json([
'message' => 'Out of stock'
], 422);
}
$order = Order::create([
'machine_id' => $machine->id,
'machine_product_id' => $machineProduct->id,
'amount' => $machineProduct->product->price,
'payment_status' => 'pending',
'delivery_status' => 'pending',
]);
return response()->json($order);
}
Điểm quan trọng:
Giá lấy từ Database, không lấy từ ESP32.
public function show(Order $order)
{
return response()->json([
'id' => $order->id,
'payment_status' => $order->payment_status,
'delivery_status' => $order->delivery_status,
]);
}
ESP32 chỉ cần biết:
pending
paid
expired
và:
pending
dispensing
success
failed
Sau khi cảm biến phát hiện lon:
POST /api/orders/10001/delivery
Dữ liệu:
{
"status": "success"
}
Server xử lý:
Order
↓
delivery_status = success
↓
stock - 1
↓
InventoryLog
Tất cả nên nằm trong một transaction.
Ví dụ:
Order #10001
Machine:
VM001
Slot:
A1
Product:
Coca
Amount:
15.000đ
Payment:
PAID
Delivery:
SUCCESS
Created:
21:10:01
Paid:
21:10:14
Delivered:
21:10:17
Đây là dữ liệu rất có giá trị cho hệ thống quản lý sau này.
Không nên viết tất cả trong main.cpp.
Có thể chia:
esp32/
│
├── main.cpp
│
├── config.h
│
├── machine/
│ ├── state_machine.cpp
│ └── state_machine.h
│
├── motor/
│ ├── motor.cpp
│ └── motor.h
│
├── sensor/
│ ├── sensor.cpp
│ └── sensor.h
│
├── display/
│ ├── display.cpp
│ └── display.h
│
├── api/
│ ├── api.cpp
│ └── api.h
│
└── payment/
├── payment.cpp
└── payment.h
Cấu trúc này sẽ giúp dự án dễ mở rộng.
main.cppKhông nên có hàng nghìn dòng code.
Ý tưởng:
void setup()
{
machine.begin();
motor.begin();
sensor.begin();
display.begin();
api.begin();
}
void loop()
{
machine.update();
}
State Machine sẽ điều phối các thành phần còn lại.
Ví dụ:
enum MachineState
{
IDLE,
SELECT,
CREATE_ORDER,
SHOW_QR,
WAIT_PAYMENT,
DISPENSE,
CHECK,
SUCCESS,
ERROR
};
Biến:
MachineState state = IDLE;
update()Ý tưởng:
void Machine::update()
{
switch (state)
{
case IDLE:
handleIdle();
break;
case SELECT:
handleSelect();
break;
case CREATE_ORDER:
handleCreateOrder();
break;
case WAIT_PAYMENT:
handlePayment();
break;
case DISPENSE:
handleDispense();
break;
case CHECK:
handleCheck();
break;
case SUCCESS:
handleSuccess();
break;
case ERROR:
handleError();
break;
}
}
Đây là cách tốt hơn rất nhiều so với:
if (...)
{
}
else if (...)
{
}
else if (...)
{
}
else if (...)
{
}
hàng trăm lần trong loop().
delay() cho toàn bộ máyKhông nên:
motorOn();
delay(5000);
motorOff();
Trong thời gian delay():
UI có thể không phản hồi.
Sensor không được xử lý tốt.
Network bị gián đoạn.
Payment không được kiểm tra.
Thay vào đó sử dụng:
millis()
hoặc một state machine không blocking.
ESP32 có thể kết nối:
Wi-Fi
Luồng:
ESP32
↓
Wi-Fi Router
↓
Internet
↓
Laravel Server
Trong phiên bản mini đặt trên bàn học, Wi-Fi là lựa chọn đơn giản nhất.
Đây là một lỗi phải tính đến.
Ví dụ:
Khách chọn Coca
↓
ESP32 mất Wi-Fi
Không được cho máy hoạt động bình thường như chưa có chuyện gì.
Màn hình:
┌──────────────────────────────┐
│ │
│ ⚠ MẤT KẾT NỐI │
│ │
│ Vui lòng thử lại sau. │
│ │
└──────────────────────────────┘
Tình huống khó hơn:
Khách thanh toán
↓
Payment = PAID
↓
ESP32 mất Wi-Fi
Không được tự động tạo Order mới.
Order cũ vẫn tồn tại trên Server.
Khi ESP32 kết nối lại:
GET Order #10001
Server trả:
PAID
Máy tiếp tục:
DISPENSE
Đây là lý do Order ID phải được lưu trên ESP32 trong quá trình giao dịch.
Một máy cơ khí thực tế nên có:
Nút dừng khẩn cấp.
Ví dụ:
🔴
EMERGENCY STOP
Khi nhấn:
Motor OFF
ngay lập tức.
Không chờ:
State Machine
xử lý xong.
Đây là một lớp bảo vệ phần cứng riêng.
Không cấp motor trực tiếp từ chân GPIO ESP32.
Sai:
ESP32 GPIO
│
▼
MOTOR
Đúng:
Power Supply
│
├──────────► Motor Driver ───► Motor
│
└──────────► Voltage Regulator ───► ESP32
ESP32 chỉ gửi:
PWM
DIR
ENABLE
tới driver.
Nếu dùng nguồn riêng cho motor và ESP32, cần thiết kế mass đúng cách.
Thông thường:
ESP32 GND
│
├──────── Motor Driver GND
│
└──────── Sensor GND
Nhưng nguồn và driver phải được thiết kế phù hợp với dòng tải và nhiễu của motor.
Đừng bắt đầu bằng:
20 sản phẩm
QR thật
Camera
4G
NFC
Cloud
Dashboard
AI
Chúng ta chỉ cần:
1 sản phẩm
1 motor
1 sensor
1 ESP32
1 màn hình
1 Backend
1 QR demo
Mục tiêu:
Một lon nước được bán thành công từ đầu đến cuối.
Sau khi chạy ổn mới mở rộng.
┌───────────────────────┐
│ │
│ 🥤 COCA │
│ │
│ 15.000đ │
│ │
│ [ MUA NGAY ] │
│ │
└──────────┬────────────┘
│
▼
Laravel
│
▼
QR
│
▼
Thanh toán
│
▼
ESP32
│
▼
Motor
│
▼
🥤
│
▼
Sensor
│
▼
SUCCESS
Nếu V1 chạy được:
Dự án đã thành công về mặt nguyên lý.
Sau đó thêm:
A1
A2
B1
B2
Kiến trúc:
ESP32
│
┌─────────┼─────────┐
↓ ↓ ↓
Motor A Motor B Motor C
│ │ │
Sensor A Sensor B Sensor C
Thêm:
QR thật
Inventory
Admin
Multiple machines
Remote monitoring
Lúc này:
CLOUD
│
┌────────┴────────┐
│ │
VM001 VM002
│ │
ESP32 ESP32
│ │
Vending Vending
Laravel Admin có thể hiển thị:
┌──────────────────────────────────────────────┐
│ VENDING DASHBOARD │
├──────────────────────────────────────────────┤
│ │
│ 🟢 VM001 🟢 VM002 🔴 VM003 │
│ Online Online Offline │
│ │
│ Doanh thu hôm nay: 1.250.000đ │
│ Đơn hàng: 87 │
│ Giao thành công: 84 │
│ Lỗi giao hàng: 3 │
│ │
├──────────────────────────────────────────────┤
│ LOW STOCK │
│ │
│ VM001 / B1 / Sprite / Còn 1 │
│ VM002 / A2 / Pepsi / Còn 0 │
└──────────────────────────────────────────────┘
Đây đã không còn đơn thuần là một Arduino project.
Nó trở thành:
Một hệ thống IoT có thiết bị + Backend + Database + Payment + Dashboard.
👤
│
▼
TOUCH SCREEN
│
▼
ESP32
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
Motor Sensor Wi-Fi
│ │ │
▼ ▼ ▼
Cơ khí IR/Encoder INTERNET
│
▼
┌─────────────┐
│ LARAVEL │
└──────┬──────┘
│
┌────────────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
ORDERS PAYMENT INVENTORY
│ │ │
└────────────────────────┼──────────────────┘
│
▼
ADMIN DASHBOARD
Chúng ta đã đi từ:
Một motor
đến:
Một hệ thống vending machine hoàn chỉnh về nguyên lý.
Cụ thể:
Khung
Trục xoắn
Motor
Driver
Cơ cấu nhận hàng
ESP32
Sensor
Encoder
Nguồn
Driver
Emergency Stop
State Machine
UI
HTTP
API
Laravel
Database
Order
Payment
Inventory
Admin
Đừng làm ngay một máy 10–20 ngăn.
Hãy làm đúng một ngăn duy nhất.
[ ] ESP32
[ ] Motor
[ ] Motor Driver
[ ] IR Sensor
[ ] Encoder/Hall Sensor
[ ] Màn hình
[ ] Nút Emergency Stop
[ ] Trục xoắn
[ ] IDLE
[ ] SELECT
[ ] CREATE_ORDER
[ ] SHOW_QR
[ ] WAIT_PAYMENT
[ ] DISPENSE
[ ] CHECK
[ ] SUCCESS
[ ] ERROR
[ ] Products
[ ] Machines
[ ] Machine Products
[ ] Orders
[ ] Inventory Logs
[ ] Payment
[ ] Chọn Coca
[ ] Kiểm tra tồn kho
[ ] Tạo Order
[ ] Hiển thị QR
[ ] Giả lập PAID
[ ] Motor quay
[ ] Sensor phát hiện lon
[ ] Ghi SUCCESS
[ ] Trừ stock
[ ] Ghi Inventory Log
[ ] Quay về IDLE
Nếu toàn bộ các bước này chạy ổn:
🏆 Bạn đã có một vending machine mini hoạt động đúng nguyên lý.
Chúng ta đã xây dựng dự án theo đúng hướng của một sản phẩm kỹ thuật:
Bài 1
↓
Ý tưởng vending machine mini
Bài 2
↓
Cấu trúc và nguyên lý cơ khí
Bài 3
↓
Cơ cấu nhả lon
Bài 4
↓
Motor + ESP32
Bài 5
↓
Cảm biến + phản hồi
Bài 6
↓
State Machine
Bài 7
↓
Touch UI
Bài 8
↓
QR Payment
Bài 9
↓
Inventory
Bài 10
↓
GHÉP TOÀN BỘ HỆ THỐNG
Và kiến trúc cuối cùng là:
🥤 VENDING MACHINE
│
┌────────────┼────────────┐
│ │ │
MECHANICAL ELECTRONIC SOFTWARE
│ │ │
Motor ESP32 State Machine
Spiral Sensor Touch UI
Frame Driver API
│ │ │
└────────────┼────────────┘
│
INTERNET
│
▼
LARAVEL
│
┌────────────┼────────────┐
│ │ │
ORDER PAYMENT INVENTORY
│ │ │
└────────────┼────────────┘
│
▼
ADMIN PANEL
Đây là mốc kết thúc phần "làm một vending machine mini chạy được".
Từ đây, phần tiếp theo có thể chuyển sang thiết kế cơ khí thực tế, PCB, nguồn, hộp máy, nhiều ngăn, làm lạnh, chống kẹt lon, giám sát từ xa và đưa máy từ prototype thành sản phẩm có thể chế tạo.
Sau 9 bài, chúng ta đã lần lượt xây dựng:Cơ cấu nhả lon.Motor và driver.ESP32.Encoder/Sensor.State Machine.Màn hình cảm ứng.Thanh toán QR.Backend Lara...
Ở bài 8, máy đã có thể:
Cho khách chọn sản phẩm.
Tạo đơn hàng.
Hiển thị QR.
Nhận xác nhận thanh toán.
Nhả sản phẩm.
Xác nhận giao hàng.
Nhưng còn một vấn đề rất quan trọng:
Máy phải biết trong mỗi ngăn còn bao nhiêu sản phẩm.
Nếu không quản lý tồn kho, khách có thể thanh toán một sản phẩm đã hết và máy mới phát hiện ra không thể giao hàng.
Giả sử máy có 4 ngăn:
A1 — Coca — 5
A2 — Pepsi — 4
B1 — Sprite — 2
B2 — Sting — 0
Trên màn hình:
┌─────────────────────────────┐
│ CHỌN SẢN PHẨM │
│ │
│ ┌────────┐ ┌────────┐ │
│ │ Coca │ │ Pepsi │ │
│ │ 15.000 │ │ 15.000 │ │
│ │ CÒN 5 │ │ CÒN 4 │ │
│ └────────┘ └────────┘ │
│ │
│ ┌────────┐ ┌────────┐ │
│ │ Sprite │ │ Sting │ │
│ │ 15.000 │ │HẾT HÀNG│ │
│ │ CÒN 2 │ │ │ │
│ └────────┘ └────────┘ │
└─────────────────────────────┘
Sản phẩm hết hàng phải được khóa ngay trên giao diện.
Đây là điểm rất quan trọng.
Ban đầu:
A1 = 5
Bán một lon:
5 → 4
Bán tiếp:
4 → 3
...
1 → 0
Đây là cách đơn giản nhất.
Có thể đặt cảm biến để phát hiện sản phẩm thực tế.
Ví dụ:
🥤
🥤
🥤
🥤
🥤
Cảm biến ở cuối ngăn phát hiện còn sản phẩm hay không.
Hoặc dùng nhiều cảm biến:
🥤
──── Sensor 1
🥤
──── Sensor 2
🥤
──── Sensor 3
Cách này phức tạp hơn nhưng chính xác hơn trong một số thiết kế.
Ở phiên bản học tập, chúng ta nên bắt đầu bằng:
Phần mềm quản lý số lượng + cảm biến xác nhận sản phẩm rơi.
Không cần lắp một cảm biến cho từng lon ngay từ đầu.
Luồng:
Bán hàng thành công
↓
Giảm tồn kho 1
↓
Cảm biến xác nhận lon rơi
↓
Hoàn tất
Giả sử:
Tồn kho = 5
Khách thanh toán thành công.
Nếu ngay lập tức:
5 → 4
nhưng motor bị kẹt thì thực tế vẫn còn 5 lon.
Vì vậy tốt hơn là:
Thanh toán thành công
↓
Bắt đầu nhả hàng
↓
Cảm biến xác nhận
↓
Giảm tồn kho
Đây là một nguyên tắc quan trọng:
Chỉ ghi nhận đã bán khi sản phẩm thực sự được giao.
Trong Laravel, chúng ta có thể tạo bảng products.
Ví dụ:
products
id
code
name
price
stock
status
created_at
updated_at
Dữ liệu:
1 | A1 | Coca | 15000 | 5 | active
2 | A2 | Pepsi | 15000 | 4 | active
3 | B1 | Sprite | 15000 | 2 | active
4 | B2 | Sting | 15000 | 0 | active
code?Không nên chỉ dùng:
id = 1
Máy cần một mã dễ hiểu:
A1
A2
B1
B2
Ví dụ:
machine_products
machine_id | product_id | slot
VM001 | 1 | A1
VM001 | 2 | A2
Cách này còn tốt hơn nếu sau này có nhiều máy.
Ví dụ:
Coca
VM001 → A1
VM002 → B2
VM003 → A4
Vì vậy không nên lưu:
products.stock
nếu stock đại diện cho tồn kho của từng máy.
Nên tách:
products
và:
machine_products
machine_productsVí dụ:
machine_products
id
machine_id
product_id
slot
stock
capacity
status
Dữ liệu:
1 | VM001 | Coca | A1 | 5 | 6 | active
2 | VM001 | Pepsi | A2 | 4 | 6 | active
3 | VM001 | Sprite | B1 | 2 | 6 | active
4 | VM001 | Sting | B2 | 0 | 6 | out_of_stock
capacity để làm gì?Ví dụ một ngăn chứa tối đa 6 lon:
capacity = 6
Hiện tại:
stock = 4
Có nghĩa:
Đã bán = 2
Còn = 4
Có thể nạp = 2
Dashboard có thể hiển thị:
A2
4 / 6
████████░░
Có thể sử dụng:
active
out_of_stock
maintenance
disabled
Ví dụ:
A1 → active
A2 → active
B1 → out_of_stock
B2 → maintenance
Hai trạng thái cuối đều không cho khách mua.
Khi khách chọn A1:
SELECT A1
Server kiểm tra:
if ($machineProduct->stock <= 0) {
return response()->json([
'message' => 'Sản phẩm đã hết hàng.'
], 422);
}
Nếu còn:
stock > 0
mới tạo Order.
Ví dụ ESP32 gửi:
{
"product": "A1"
}
Server tự tìm:
A1
↓
Coca
↓
15.000đ
↓
stock = 5
Không nên để ESP32 gửi:
{
"product": "A1",
"price": 1000,
"stock": 99
}
Server phải là nơi quyết định dữ liệu quan trọng.
Giả sử:
A1 = 5
Khách mua.
Cảm biến xác nhận:
PRODUCT_DETECTED
Server cập nhật:
5 → 4
Ví dụ Laravel:
$machineProduct->decrement('stock');
Nếu:
stock = 1
Khách mua thành công:
1 → 0
Server chuyển:
status = out_of_stock
Giao diện:
┌──────────────┐
│ COCA │
│ │
│ HẾT HÀNG │
└──────────────┘
stock = 0Có thể xảy ra:
stock = 0
status = active
Điều này không tốt.
Tốt hơn là khi cập nhật:
if ($machineProduct->stock <= 0) {
$machineProduct->update([
'stock' => 0,
'status' => 'out_of_stock',
]);
}
Ví dụ:
A1 = 2
Nhân viên nạp thêm 4 lon:
2 + 4 = 6
Dashboard có nút:
[NẠP HÀNG]
Nhập:
Số lượng: 4
Server cập nhật:
stock = 6
status = active
Máy lập tức cho phép bán lại.
Một hệ thống thực tế nên có lịch sử nhập/xuất kho.
Ví dụ:
inventory_logs
id
machine_product_id
type
quantity
reason
created_at
Ví dụ:
08:00 RESTOCK +6
10:15 SALE -1
10:30 SALE -1
11:02 SALE -1
12:20 RESTOCK +3
Ta có thể biết chính xác:
Tại sao hôm nay A1 còn 6 lon?
Có thể dùng:
RESTOCK
SALE
ADJUSTMENT
DAMAGE
EXPIRED
RETURN
Ví dụ:
RESTOCK +6
SALE -1
DAMAGE -1
Ví dụ:
InventoryLog::create([
'machine_product_id' => $machineProduct->id,
'type' => 'SALE',
'quantity' => -1,
'reason' => 'Order #' . $order->id,
]);
Sau đó:
$machineProduct->decrement('stock');
Như vậy hệ thống vừa:
cập nhật tồn kho
lưu lịch sử
Đây là phần rất quan trọng.
Không nên:
Giảm stock
↓
Tạo log
↓
Lỗi
và cuối cùng dữ liệu bị lệch.
Laravel nên sử dụng database transaction:
DB::transaction(function () use ($machineProduct, $order) {
$machineProduct->decrement('stock');
InventoryLog::create([
'machine_product_id' => $machineProduct->id,
'type' => 'SALE',
'quantity' => -1,
'reason' => 'Order #' . $order->id,
]);
});
Nếu một thao tác lỗi:
Toàn bộ transaction được rollback.
Giả sử chỉ còn:
stock = 1
Hai khách cùng lúc mua A1.
Nếu xử lý không cẩn thận:
Khách A → thấy 1
Khách B → thấy 1
A mua
B mua
1 → 0 → -1
Không được phép xảy ra.
Server cần khóa hoặc cập nhật dữ liệu một cách an toàn.
Ví dụ sử dụng transaction và lockForUpdate():
DB::transaction(function () use ($machineProduct) {
$machineProduct = MachineProduct::whereKey(
$machineProduct->id
)
->lockForUpdate()
->first();
if ($machineProduct->stock <= 0) {
throw new RuntimeException('Out of stock');
}
$machineProduct->decrement('stock');
});
Đây là một kỹ thuật rất quan trọng khi hệ thống có nhiều người dùng cùng thao tác.
Đến đây phần mềm đã quản lý được:
A1 = 5
Nhưng máy vẫn cần biết sản phẩm thực tế có rơi hay không.
Đó là nhiệm vụ của cảm biến.
Ví dụ:
🥤
🥤
🥤
🥤
🥤
↓
┌─────────┐
│ IR │
│ SENSOR │
└─────────┘
Khi lon đi qua:
Sensor = DETECTED
ESP32 gửi:
{
"machine": "VM001",
"slot": "A1",
"event": "product_delivered"
}
Vì hai thứ kiểm tra hai vấn đề khác nhau.
Theo hệ thống, còn bao nhiêu?
Sản phẩm có thực sự được giao không?
Kết hợp:
DATABASE
+
SENSOR
=
INVENTORY CONTROL
Ví dụ:
Payment = PAID
↓
Motor ON
↓
Encoder = OK
↓
Motor OFF
↓
IR Sensor = NO PRODUCT
Không được giảm kho.
Order:
payment_status = paid
delivery_status = failed
Máy có thể:
Thử lại
↓
Nếu vẫn lỗi
↓
Báo nhân viên
Không cần chờ stock = 0.
Ví dụ:
stock <= 2
thì:
LOW STOCK
Dashboard:
⚠ A1 — Coca
Còn 2 / 6
Có thể đặt:
stock > 2
→ NORMAL
stock <= 2
→ LOW STOCK
stock = 0
→ OUT OF STOCK
Sau này có thể cho mỗi sản phẩm một mức riêng:
min_stock = 2
Laravel Admin có thể hiển thị:
┌─────────────────────────────────────────────┐
│ INVENTORY │
├──────┬──────────┬───────┬────────┬─────────┤
│ Slot │ Product │ Stock │ Status │ Action │
├──────┼──────────┼───────┼────────┼─────────┤
│ A1 │ Coca │ 5/6 │ Normal │ │
│ A2 │ Pepsi │ 2/6 │ Low │ │
│ B1 │ Sprite │ 0/6 │ Empty │ │
│ B2 │ Sting │ 4/6 │ Normal │ │
└──────┴──────────┴───────┴────────┴─────────┘
Khi có nhiều vending machine:
VM001
├── A1 Coca 5
├── A2 Pepsi 2 ⚠
├── B1 Sprite 0 ❌
└── B2 Sting 6
VM002
├── A1 Coca 4
├── A2 Pepsi 5
├── B1 Sprite 3
└── B2 Sting 0 ❌
Admin có thể nhìn thấy ngay:
Máy nào cần nạp hàng.
Khi:
stock <= min_stock
Server có thể tạo:
Restock Task
Ví dụ:
TASK #1025
Machine: VM001
Slot: B1
Product: Sprite
Current: 0
Capacity: 6
→ CẦN NẠP 6 LON
Đây là bước đầu tiên để xây dựng hệ thống quản lý vận hành thực tế.
Bây giờ một giao dịch sẽ đi qua:
Khách chọn sản phẩm
↓
Kiểm tra tồn kho
↓
Tạo Order
↓
Thanh toán QR
↓
Payment = PAID
↓
ESP32 DISPENSE
↓
Motor quay
↓
Encoder xác nhận vị trí
↓
IR Sensor xác nhận lon
↓
Delivery = SUCCESS
↓
Stock - 1
↓
Inventory Log
↓
Kiểm tra Low Stock
↓
Hoàn tất
Đây là một luồng rất gần với cách chúng ta sẽ xây dựng phiên bản thực tế.
Chưa cần làm cảm biến tồn kho phức tạp.
Hãy xây dựng trước phần Backend.
A1 — Coca — 15.000đ
A2 — Pepsi — 15.000đ
B1 — Sprite — 15.000đ
B2 — Sting — 15.000đ
VM001
A1 → Coca
A2 → Pepsi
B1 → Sprite
B2 → Sting
A1 = 6
A2 = 6
B1 = 3
B2 = 0
Trên giao diện máy:
B2 → HẾT HÀNG
và không cho phép chọn.
Giả lập một giao dịch A1 thành công.
6 → 5
Tạo một InventoryLog:
SALE -1
Thử giảm:
3 → 2
Giao diện Admin phải hiển thị:
LOW STOCK
Sau bài 9, máy của chúng ta đã có thêm một hệ thống quan trọng:
VENDING MACHINE
ESP32
│
┌────────────┼────────────┐
↓ ↓ ↓
Motor Sensor UI
│ │
└──────┬─────┘
↓
SERVER
│
┌──────┼──────┐
↓ ↓ ↓
Order Stock Logs
│ │
└──────┼──────┘
↓
ADMIN PANEL
Điểm quan trọng nhất:
Thanh toán thành công chưa đồng nghĩa với bán hàng thành công. Chỉ khi cảm biến xác nhận sản phẩm đã được giao thì hệ thống mới trừ tồn kho.
Và từ đây, máy đã bắt đầu có khả năng tự vận hành và báo tình trạng cho người quản lý.
Bài tiếp theo sẽ ghép toàn bộ những gì đã học:
Màn hình
↓
Chọn sản phẩm
↓
Laravel
↓
QR Payment
↓
Xác nhận thanh toán
↓
ESP32
↓
Motor + Encoder
↓
Cảm biến
↓
Giao sản phẩm
↓
Trừ kho
↓
Laravel Admin
Đây sẽ là bài lắp ghép toàn bộ hệ thống thành một vending machine mini hoàn chỉnh, thay vì chỉ học từng linh kiện riêng lẻ.
Ở bài 8, máy đã có thể:Cho khách chọn sản phẩm.Tạo đơn hàng.Hiển thị QR.Nhận xác nhận thanh toán.Nhả sản phẩm.Xác nhận giao hàng.Nhưng còn một vấn đề ...
Ở các bài trước, máy của chúng ta đã có:
ESP32 điều khiển.
Motor và cơ cấu trục xoắn.
Encoder/Hall Sensor.
Cảm biến phát hiện sản phẩm.
State Machine.
Giao diện cảm ứng.
Bây giờ chúng ta thêm một thành phần rất quan trọng:
Thanh toán QR.
Mục tiêu cuối cùng là:
Khách chọn Coca
↓
Máy tạo giao dịch
↓
Hiển thị QR
↓
Khách quét QR
↓
Thanh toán thành công
↓
Server xác nhận
↓
ESP32 nhận lệnh
↓
Nhả Coca
Đây là nguyên tắc quan trọng.
Không nên thiết kế:
QR
↓
ESP32
↓
Ngân hàng
ESP32 không nên trực tiếp xử lý:
thông tin tài khoản ngân hàng
thông tin thẻ
chữ ký giao dịch
khóa bí mật API
xác thực thanh toán
Thay vào đó, chúng ta cần một Backend Server.
Kiến trúc:
KHÁCH
│
▼
Quét mã QR
│
▼
Cổng thanh toán
│
▼
SERVER
│
▼
ESP32
│
▼
MOTOR
│
▼
🥤
Giả sử Coca có giá:
15.000đ
ESP32 yêu cầu server tạo một giao dịch:
POST /api/orders
Dữ liệu:
{
"machine": "VM001",
"product": "A1",
"amount": 15000
}
Server tạo:
Order #10025
Sau đó trả về thông tin thanh toán.
Một sai lầm rất nguy hiểm là:
Hiển thị QR
↓
Khách quét
↓
ESP32 tự cho rằng đã thanh toán
Không được.
Khách có thể:
chụp ảnh QR.
gửi ảnh cho người khác.
không thanh toán.
thanh toán sai số tiền.
Máy chỉ được nhả hàng khi:
Server xác nhận giao dịch hợp lệ.
Khi khách chọn:
A1
Coca
15.000đ
ESP32 gửi:
machine = VM001
product = A1
amount = 15000
Server tạo một Order:
orders
id: 10025
machine: VM001
product: A1
amount: 15000
status: pending
Ban đầu:
pending
Server có thể tạo dữ liệu QR dựa trên giao dịch.
Ví dụ:
VietQR
Nội dung có thể chứa:
Ngân hàng
Số tài khoản
Số tiền
Nội dung chuyển khoản
Ví dụ nội dung:
VM001-10025
Khách chuyển:
15.000đ
với nội dung:
VM001-10025
Server sẽ dùng thông tin giao dịch để xác định tiền thuộc về đơn hàng nào.
Không nên dùng:
COCA
làm nội dung thanh toán.
Vì trong một ngày có thể có:
COCA
COCA
COCA
COCA
Thay vào đó:
VM001-10025
VM001-10026
VM001-10027
Mỗi giao dịch có một ID riêng.
Ở bài 6 chúng ta có:
IDLE
↓
SELECT
↓
PAYMENT
↓
DISPENSE
↓
CHECK
↓
SUCCESS
Bây giờ PAYMENT chi tiết hơn:
SELECT
↓
CREATE_ORDER
↓
SHOW_QR
↓
WAIT_PAYMENT
↓
PAYMENT_SUCCESS
↓
DISPENSE
Nếu hết thời gian:
WAIT_PAYMENT
↓
TIMEOUT
↓
IDLE
Không thể để máy chờ vô hạn.
Ví dụ:
Thời gian thanh toán:
120 giây
Nếu khách không thanh toán:
PAYMENT TIMEOUT
Máy trở về:
IDLE
Đơn hàng:
pending
có thể được đánh dấu:
expired
Có hai cách phổ biến.
Cổng thanh toán gửi thông báo cho server:
Payment Provider
↓
POST /api/payment/webhook
↓
Laravel
Server kiểm tra:
Giao dịch có tồn tại?
Số tiền có đúng?
Mã giao dịch có đúng?
Trạng thái có thành công?
Chữ ký có hợp lệ?
Nếu tất cả đúng:
status = paid
Nếu hệ thống thanh toán hoặc kiến trúc của bạn không thuận tiện cho webhook trực tiếp đến thiết bị, ESP32 có thể hỏi server:
GET /api/orders/10025
Server trả:
{
"id": 10025,
"status": "paid"
}
ESP32:
pending
↓
pending
↓
pending
↓
paid
Khi nhận:
paid
thì:
DISPENSE
| Phương pháp | Ưu điểm | Nhược điểm |
|---|---|---|
| Webhook | Nhanh, hiệu quả | Backend phải nhận callback |
| Polling | Dễ hiểu, dễ làm prototype | Tốn request |
| Kết hợp | Linh hoạt | Kiến trúc phức tạp hơn |
Đối với phiên bản học tập, chúng ta có thể bắt đầu bằng:
ESP32 polling Server
Sau khi hệ thống chạy ổn định mới chuyển sang kiến trúc nâng cao.
Vì chúng ta đang học Laravel, Backend có thể xây dựng bằng Laravel.
Ví dụ:
POST /api/orders
GET /api/orders/{order}
POST /api/payment/webhook
Controller:
public function store(Request $request)
{
$order = Order::create([
'machine_code' => $request->machine,
'product_code' => $request->product,
'amount' => $request->amount,
'status' => 'pending',
]);
return response()->json([
'order_id' => $order->id,
'status' => $order->status,
]);
}
Sau này có thể bổ sung:
QR data.
Transaction ID.
Expired time.
Payment provider.
Signature.
Một bảng orders đơn giản:
orders
id
machine_code
product_code
amount
status
transaction_code
paid_at
created_at
updated_at
Ví dụ:
10025
VM001
A1
15000
paid
VM001-10025
2026-08-09 12:05:32
ESP32 gửi:
{
"product": "A1",
"amount": 15000
}
Nhưng server không nên tin tuyệt đối amount.
Ví dụ người dùng sửa request thành:
{
"product": "A1",
"amount": 100
}
Nếu server tin dữ liệu này thì máy có thể bị khai thác.
Server phải lấy giá từ database:
A1 → Coca → 15.000đ
Sau đó tự xác định:
amount = 15000
Nên thiết kế:
ESP32
│
│ product=A1
▼
Laravel
│
│ tìm giá A1
▼
15.000đ
│
▼
Create Order
│
▼
Generate QR
│
▼
Customer Payment
│
▼
Payment Provider
│
▼
Webhook
│
▼
Laravel
│
▼
Order = PAID
│
▼
ESP32
│
▼
DISPENSE
Ví dụ:
bool checkPayment(int orderId)
{
// Gửi HTTP request đến server
// Server trả về:
// pending
// paid
// expired
return false;
}
Trong State Machine:
case WAIT_PAYMENT:
if (checkPayment(orderId)) {
currentState = DISPENSE;
}
break;
delay()Không nên:
delay(5000);
rồi kiểm tra thanh toán.
Thay vào đó:
if (millis() - lastCheck >= 2000) {
lastCheck = millis();
checkPayment(orderId);
}
ESP32 có thể đồng thời:
cập nhật màn hình.
kiểm tra cảm ứng.
kiểm tra thanh toán.
xử lý cảm biến.
giám sát motor.
Đây là tình huống rất quan trọng.
Ví dụ:
Khách thanh toán 15.000đ
↓
Payment = PAID
↓
Motor chạy
↓
Lon bị kẹt
Không được chỉ hiện:
ERROR
và kết thúc.
Server phải ghi nhận:
payment = paid
delivery = failed
Sau đó hệ thống có thể:
thử nhả lại.
đánh dấu cần hoàn tiền.
thông báo nhân viên.
ghi log sự cố.
Nên tách thanh toán và giao hàng.
Ví dụ:
payment_status
----------------
pending
paid
failed
expired
refunded
và:
delivery_status
----------------
pending
dispensing
success
failed
Như vậy:
PAID + DELIVERY_FAILED
không bị nhầm thành:
PAYMENT_FAILED
Ví dụ khách mua A1:
12:10:01
SELECT A1
12:10:02
CREATE ORDER #10025
12:10:03
SHOW QR
12:10:15
PAYMENT PAID
12:10:16
DISPENSING
12:10:18
PRODUCT DETECTED
12:10:18
DELIVERY SUCCESS
Server có thể lưu toàn bộ lịch sử.
Vì Backend dùng Laravel, sau này chúng ta có thể tạo:
ADMIN DASHBOARD
Hiển thị:
Doanh thu hôm nay
------------------
1.250.000đ
Đơn hàng
------------------
87
Đã giao
------------------
84
Lỗi
------------------
3
Danh sách:
Order Machine Product Amount Status
10025 VM001 A1 15.000 SUCCESS
10026 VM001 B2 20.000 SUCCESS
10027 VM001 A1 15.000 ERROR
Đây chính là bước kết nối dự án máy bán nước với những kiến thức Laravel Admin CRUD mà chúng ta đã học trước đó.
Ngay từ đầu nên có:
machine_code
Ví dụ:
VM001
VM002
VM003
Server sẽ biết:
Order #10025
Machine:
VM002
Sau này một hệ thống Laravel có thể quản lý:
SERVER
┌───────┼───────┐
↓ ↓ ↓
VM001 VM002 VM003
↓ ↓ ↓
ESP32 ESP32 ESP32
Đây chính là nền tảng để biến một mô hình học tập thành hệ thống IoT vending machine.
Khi đưa máy ra Internet, không được để:
ESP32 → API
mà không xác thực.
Có thể dùng:
API Key
hoặc token riêng cho từng máy:
VM001
TOKEN_XXXXXXXX
Server kiểm tra token trước khi trả dữ liệu.
Ngoài ra:
Chỉ sử dụng HTTPS.
Không lưu secret thanh toán trong firmware nếu không cần.
Không cho ESP32 tự quyết định giao dịch đã thanh toán.
Kiểm tra số tiền ở server.
Kiểm tra mã giao dịch.
Chống xử lý một giao dịch hai lần.
Giả sử ESP32 hỏi:
Order #10025 = paid
và nhận được:
paid
ESP32 nhả hàng.
Nhưng do mạng chập chờn, ESP32 lại hỏi lần nữa:
Order #10025 = paid
Nếu chương trình không cẩn thận:
Motor
↓
Lon 1
Motor
↓
Lon 2
Để tránh điều này, Order cần có trạng thái giao hàng:
delivery_status = dispensing
Sau khi giao thành công:
delivery_status = success
ESP32 không được nhả lại Order đã có:
success
Sau bài 8:
┌──────────┐
│ IDLE │
└────┬─────┘
↓
┌──────────┐
│ SELECT │
└────┬─────┘
↓
┌──────────────┐
│ CREATE_ORDER │
└──────┬───────┘
↓
┌──────────────┐
│ SHOW_QR │
└──────┬───────┘
↓
┌────────────────┐
│ WAIT_PAYMENT │
└───────┬────────┘
│
┌─────────┴─────────┐
↓ ↓
TIMEOUT PAID
↓ ↓
IDLE DISPENSE
↓
CHECK
↙ ↘
SUCCESS ERROR
↓ ↓
IDLE SUPPORT
Đây đã là một luồng giao dịch hoàn chỉnh.
Chưa cần kết nối ngân hàng thật.
Hãy xây dựng trước một phiên bản giả lập thanh toán:
Khách chọn:
A1
Laravel tạo:
Order #10001
với:
status = pending
Màn hình ESP32 hiển thị:
15.000đ
[ QR DEMO ]
Tạo một nút trong Admin:
[ XÁC NHẬN THANH TOÁN ]
Admin bấm nút.
Database chuyển:
pending
↓
paid
ESP32 nhận:
paid
và chuyển:
WAIT_PAYMENT
↓
DISPENSE
Motor nhả sản phẩm.
Như vậy bạn có thể kiểm tra toàn bộ kiến trúc thanh toán mà chưa cần kết nối dịch vụ thanh toán thật.
Sau bài 8, kiến trúc của máy đã tiến thêm một bước lớn:
INTERNET
│
▼
┌────────────┐
│ LARAVEL │
│ SERVER │
└─────┬──────┘
│
Payment API
│
▼
QR
│
▼
KHÁCH
│
Thanh toán
│
▼
┌────────────┐
│ LARAVEL │
└─────┬──────┘
│
PAID
│
▼
ESP32
│
▼
MOTOR
│
▼
🥤
Điểm quan trọng nhất của bài này là:
Thanh toán và nhả hàng phải là hai hệ thống riêng biệt, được kết nối bằng trạng thái giao dịch.
Đừng để ESP32 tự quyết định "đã thanh toán". Server phải xác nhận giao dịch, sau đó ESP32 mới được phép nhả sản phẩm.
Ở bài tiếp theo, chúng ta sẽ giải quyết một vấn đề rất thực tế:
Máy biết còn bao nhiêu lon?
Chúng ta sẽ xây dựng:
A1 → Coca → 5 lon
A2 → Pepsi → 2 lon
B1 → Sprite → 0 lon
Khi bán một lon:
5 → 4
Khi còn:
0
giao diện tự chuyển thành:
HẾT HÀNG
và hệ thống quản trị Laravel có thể biết máy nào đang cần nạp hàng.
Ở các bài trước, máy của chúng ta đã có:ESP32 điều khiển.Motor và cơ cấu trục xoắn.Encoder/Hall Sensor.Cảm biến phát hiện sản phẩm.State Machine.Giao ...
Ở bài 6, chúng ta đã xây dựng State Machine cho bộ não của máy.
Bây giờ đến phần mà khách hàng nhìn thấy đầu tiên:
Giao diện người dùng (UI/UX).
Một máy bán nước có phần cơ khí rất tốt nhưng giao diện khó sử dụng thì trải nghiệm vẫn rất tệ.
Mục tiêu của chúng ta là tạo một giao diện hiện đại, đơn giản, dễ bấm, lấy cảm hứng từ phong cách giao diện Windows/Material nhưng không sao chép logo hay nhận diện thương hiệu.
Phiên bản mini có thể bắt đầu với:
Màn hình cảm ứng 3.5–4.3 inch.
ESP32.
Độ phân giải khoảng 480×272 hoặc 800×480.
Giao tiếp SPI hoặc RGB tùy loại màn hình.
Nếu muốn làm giao diện đẹp hơn, có thể chọn:
ESP32 + màn hình cảm ứng 4.3 inch + LVGL
LVGL là thư viện giao diện rất phù hợp cho các thiết bị nhúng.
Một lỗi phổ biến khi thiết kế máy bán hàng là đưa quá nhiều thông tin lên màn hình.
Ví dụ không nên:
┌───────────────────────────────┐
│ A1 Coca 10.000 │ A2 Pepsi... │
│ B1 Sprite ... │ B2 Fanta... │
│ C1 Water ... │ C2 Tea ... │
│ │
│ QR │ CASH │ CARD │ SETTINGS │
└───────────────────────────────┘
Khách nhìn vào sẽ mất thời gian.
Chúng ta nên thiết kế:
┌───────────────────────────────┐
│ CHỌN ĐỒ UỐNG │
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ 🥤 │ │ 🥤 │ │
│ │ Coca │ │ Pepsi │ │
│ │ 15.000đ │ │ 15.000đ │ │
│ └─────────┘ └─────────┘ │
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ 🥤 │ │ 🥤 │ │
│ │ Sprite │ │ Fanta │ │
│ │ 15.000đ │ │ 15.000đ │ │
│ └─────────┘ └─────────┘ │
└───────────────────────────────┘
Khách chỉ cần:
Nhìn → chọn → thanh toán → nhận hàng.
Điểm quan trọng là:
UI không được tách rời State Machine.
Mỗi trạng thái của máy tương ứng với một màn hình.
IDLE
↓
Màn hình chờ
SELECT
↓
Màn hình chọn sản phẩm
PAYMENT
↓
Màn hình thanh toán
DISPENSE
↓
Màn hình đang lấy hàng
CHECK
↓
Màn hình kiểm tra
SUCCESS
↓
Màn hình hoàn tất
ERROR
↓
Màn hình báo lỗi
Như vậy phần mềm rất dễ quản lý.
Khi không có người sử dụng:
┌──────────────────────────────┐
│ │
│ VENDING │
│ │
│ 🥤 DRINK BAR │
│ │
│ Chạm để chọn sản phẩm │
│ │
│ 10:30 │
└──────────────────────────────┘
Có thể thêm:
Logo máy.
Hình ảnh sản phẩm.
Nhiệt độ.
Khuyến mãi.
Wi-Fi/4G.
Đồng hồ.
Nhưng không nên nhồi quá nhiều thông tin.
Đây là màn hình quan trọng nhất.
Ví dụ:
┌──────────────────────────────┐
│ Chọn sản phẩm │
│ │
│ ┌────────┐ ┌────────┐ │
│ │ Coca │ │ Pepsi │ │
│ │ 15.000 │ │ 15.000 │ │
│ │ A1 │ │ A2 │ │
│ └────────┘ └────────┘ │
│ │
│ ┌────────┐ ┌────────┐ │
│ │ Sprite │ │ Fanta │ │
│ │ 15.000 │ │ 15.000 │ │
│ │ B1 │ │ B2 │ │
│ └────────┘ └────────┘ │
└──────────────────────────────┘
Mỗi sản phẩm nên hiển thị:
Hình ảnh.
Tên.
Giá.
Mã sản phẩm.
Trạng thái còn hàng.
Không nên để khách bấm vào sản phẩm rồi mới báo hết hàng.
Ngay trên UI:
┌───────────────┐
│ │
│ Coca │
│ │
│ HẾT HÀNG │
│ │
└───────────────┘
Nút phải chuyển sang trạng thái:
DISABLED
Không thể nhấn.
Khi khách chọn Coca:
┌──────────────────────────────┐
│ COCA │
│ │
│ 🥤 │
│ │
│ 15.000đ │
│ │
│ [ MUA NGAY ] │
│ │
│ [ QUAY LẠI ] │
└──────────────────────────────┘
Không nên bắt khách đi qua quá nhiều màn hình.
Mục tiêu:
Từ màn hình chính đến thanh toán trong 1–2 thao tác.
Ví dụ:
┌──────────────────────────────┐
│ THANH TOÁN │
│ │
│ Coca │
│ 15.000đ │
│ │
│ ┌───────────────┐ │
│ │ QR │ │
│ │ ██████ │ │
│ │ ██ ██ │ │
│ └───────────────┘ │
│ │
│ Quét mã để thanh toán │
│ │
│ [ HỦY GIAO DỊCH ] │
└──────────────────────────────┘
Sau này có thể thêm:
QR
NFC
Thẻ
Tiền mặt
Nhưng bản mini đầu tiên chỉ cần QR là đủ.
Không chuyển ngay sang màn hình khác.
Nên cho khách thấy:
┌──────────────────────────────┐
│ │
│ ✓ │
│ │
│ THANH TOÁN THÀNH CÔNG │
│ │
│ 15.000đ │
│ │
└──────────────────────────────┘
Sau khoảng thời gian rất ngắn:
PAYMENT
↓
DISPENSE
Đây là phần rất quan trọng.
Không nên để màn hình đứng yên.
┌──────────────────────────────┐
│ │
│ ĐANG LẤY HÀNG │
│ │
│ ↻ │
│ │
│ Vui lòng chờ... │
│ │
└──────────────────────────────┘
Có thể hiển thị animation:
Motor
↻
↻
↻
Trong lúc đó ESP32 thực hiện:
Motor ON
↓
Encoder đếm
↓
Đủ vị trí
↓
Motor OFF
Sau khi motor dừng:
DISPENSE
↓
CHECK
↓
IR Sensor
Nếu cảm biến phát hiện lon:
SUCCESS
Nếu không:
ERROR
UI lúc này có thể hiển thị:
✓ Đã giao sản phẩm
Vui lòng lấy hàng.
Không nên sử dụng những thông báo kỹ thuật như:
ERROR CODE: MOTOR_04
Khách hàng không hiểu.
Thay bằng:
┌──────────────────────────────┐
│ ! │
│ │
│ KHÔNG THỂ LẤY HÀNG │
│ │
│ Vui lòng thử lại hoặc liên │
│ hệ nhân viên hỗ trợ. │
│ │
│ [ TRỞ VỀ ] │
└──────────────────────────────┘
Mã lỗi kỹ thuật chỉ dành cho màn hình quản trị.
Với concept máy mà chúng ta đang thiết kế, có thể sử dụng:
Background
#F5F7FA
Primary
#2563EB
Success
#16A34A
Warning
#F59E0B
Danger
#DC2626
Text
#111827
Không cần sử dụng quá nhiều màu.
Quy tắc đơn giản:
1 màu chính + 1 màu cảnh báo + 1 màu thành công.
Nút cảm ứng phải đủ lớn.
Không nên:
[ OK ]
quá nhỏ.
Nên:
┌──────────────────────┐
│ MUA NGAY │
└──────────────────────┘
Khách đứng trước máy có thể:
tay ướt
ánh sáng mạnh
thao tác nhanh
Vì vậy vùng cảm ứng phải đủ rộng.
Animation chỉ nên giúp khách hiểu:
Đang xử lý
Đang thanh toán
Đang nhả hàng
Hoàn tất
Không nên:
hiệu ứng bay quá nhiều
chuyển cảnh quá lâu
animation nặng
video nền
Máy bán hàng cần:
Nhanh hơn là đẹp.
Chúng ta có thể tổ chức phần mềm:
UI
│
├── HomeScreen
├── ProductScreen
├── PaymentScreen
├── DispenseScreen
├── SuccessScreen
└── ErrorScreen
Trong khi phần điều khiển:
Core
│
├── VendingMachine
├── MotorController
├── SensorController
├── PaymentController
└── Inventory
Hai phần này giao tiếp với nhau.
UI
│
▼
State Machine
│
┌────────┼────────┐
▼ ▼ ▼
Motor Sensor Payment
Đây là cách tổ chức tốt hơn rất nhiều so với việc nhét toàn bộ chương trình vào loop().
Ở phiên bản đầu tiên, chúng ta có thể dùng LVGL để xây dựng UI.
Kiến trúc:
ESP32
│
├── LVGL
│ │
│ ├── Button
│ ├── Label
│ ├── Image
│ ├── Screen
│ └── Animation
│
├── Touch Driver
│
└── Vending Controller
LVGL xử lý giao diện.
ESP32 xử lý logic máy.
Một ví dụ đơn giản với LVGL:
lv_obj_t *btn;
btn = lv_button_create(lv_screen_active());
lv_obj_set_size(btn, 200, 70);
lv_obj_center(btn);
lv_obj_t *label = lv_label_create(btn);
lv_label_set_text(label, "MUA NGAY");
lv_obj_center(label);
Khi khách chạm vào nút, chương trình có thể chuyển State Machine:
currentState = SELECT;
Hoặc nếu đang ở màn hình chọn sản phẩm:
selectedProduct = 0;
currentState = PAYMENT;
Đây là nguyên tắc rất quan trọng.
Không nên:
Button
↓
Motor
Mà phải:
Button
↓
UI Event
↓
State Machine
↓
Motor Controller
↓
Motor
Như vậy sau này:
màn hình bị thay đổi
thêm nút vật lý
thêm app điện thoại
thêm Web Admin
thì Motor Controller vẫn giữ nguyên.
Đến đây, hệ thống của chúng ta đã có kiến trúc:
TOUCH SCREEN
│
▼
UI/LVGL
│
▼
STATE MACHINE
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
PAYMENT MOTOR SENSOR
│ │ │
▼ ▼ ▼
QR/API DRIVER IR/ENCODER
│
▼
MOTOR
│
▼
SPIRAL
│
▼
🥤
Đây chính là lúc dự án bắt đầu giống một sản phẩm thực tế, thay vì chỉ là một mạch ESP32 điều khiển motor.
Trong bài này chưa cần làm toàn bộ giao diện.
Hãy làm theo thứ tự:
Tạo màn hình:
CHỌN SẢN PHẨM
Tạo 4 nút:
A1 A2
B1 B2
Khi nhấn A1:
selectedProduct = A1;
Chuyển sang:
PAYMENT
Giả lập thanh toán thành công bằng một nút:
THANH TOÁN
Chuyển:
PAYMENT
↓
DISPENSE
Sau đó mới kết nối với motor thật.
Sau bài 7, chúng ta đã thiết kế được giao diện người dùng hoàn chỉnh về mặt luồng:
┌──────────┐
│ IDLE │
└────┬─────┘
↓
┌──────────┐
│ SELECT │
└────┬─────┘
↓
┌──────────┐
│ PAYMENT │
└────┬─────┘
↓
┌──────────┐
│ DISPENSE │
└────┬─────┘
↓
┌──────────┐
│ CHECK │
└────┬─────┘
↓
┌──────────┐
│ SUCCESS │
└────┬─────┘
↓
IDLE
Quan trọng nhất là chúng ta đã thống nhất một nguyên tắc:
UI chỉ nhận thao tác của người dùng. State Machine quyết định máy phải làm gì. Controller điều khiển phần cứng.
Đây sẽ là nền tảng để các bài sau không biến thành một đống code Arduino khó bảo trì.
Ở bài tiếp theo, chúng ta sẽ đưa thanh toán thật vào máy: tạo mã QR theo sản phẩm, nhận trạng thái thanh toán qua Internet, xác nhận giao dịch rồi mới cho phép ESP32 kích hoạt cơ cấu nhả lon.
Ở bài 6, chúng ta đã xây dựng State Machine cho bộ não của máy.Bây giờ đến phần mà khách hàng nhìn thấy đầu tiên:Giao diện người dùng (UI/UX).Một máy ...
Laravel không có một thư mục helpers mặc định. Nếu bạn muốn tự viết sanitizeHtml() thì nên tự tạo một helper riêng.
Với project của bạn, mình khuyên:
app/
└── Helpers/
└── helpers.php
app/Helpers/helpers.php
Ví dụ:
<?php
if (! function_exists('sanitizeHtml')) {
function sanitizeHtml(string $html): string
{
// code sanitize của bạn
return $html;
}
}
Mở:
composer.json
Tìm:
"autoload": {
"psr-4": {
"App\\": "app/",
...
}
}
thêm files:
"autoload": {
"files": [
"app/Helpers/helpers.php"
],
"psr-4": {
"App\\": "app/"
}
}
Nếu autoload của bạn đã có files thì chỉ thêm đường dẫn vào mảng đó.
composer dump-autoload
Sau đó có thể gọi ở bất kỳ Controller, Model, Service...:
$content = sanitizeHtml($request->content);
hoặc trực tiếp:
'content' => sanitizeHtml($request->content),
app/
├── Helpers/
│ └── helpers.php
│
├── Http/
│ └── Controllers/
│
├── Models/
│
└── ...
Đây là cách khá gọn cho những function global, dùng nhiều nơi, như:
sanitizeHtml()
formatMoney()
slugify()
formatDate()
Còn nếu function chỉ phục vụ riêng cho Post, thì tốt hơn đưa nó vào Service/Action riêng thay vì biến mọi thứ thành global helper.
Laravel không có một thư mục helpers mặc định. Nếu bạn muốn tự viết sanitizeHtml() thì nên tự tạo một helper riêng.Với project của bạn, mình khuyên:ap...
Gần 1/5 thanh thiếu niên và người trẻ Mỹ từng hỏi chatbot AI về sức khỏe tâm thần, trong đó nhiều người xem lời khuyên là hữu ích.
Điều hòa là thiết bị có giá trị lớn, thường được sử dụng liên tục trong nhiều năm. Theo các chuyên gia điện lạnh, để có thể an tâm sử dụng lâu dài, người tiêu dùng không nên bỏ qua 3 lưu ý quan trọng dưới đây khi chọn mua điều hòa.
Cần Thơ đưa vào hoạt động 27 điểm phát sóng công cộng 5G và thí điểm mạng 6G tại nhiều địa điểm nổi tiếng của địa phương.
OpenAI chuẩn bị gỡ giới hạn chat văn bản trên ChatGPT Free và Go, đồng thời thêm nút Think để xử lý các câu hỏi phức tạp hơn.
Nhật Bản khuyến nghị bảo vệ ‘quyền giọng nói’ của người nổi tiếng trước AI, trong đó hành vi giả giọng để phát tán, kiếm tiền có thể bị xử lý.