Schematik build

AI Nutrition Kitchen Scale

Schematik

Published August 14, 2026 · Updated August 14, 2026

ESP32Advanced2–3 hours
Photo of AI Nutrition Kitchen Scale

This project builds an intelligent kitchen scale that weighs food and identifies it using computer vision. The scale combines a 1 kg load cell with an ESP32-C6 microcontroller for precise weight measurement, while a separate ESP32-S3 Sense module with camera captures images of the food. The two nodes communicate wirelessly via ESP-NOW to coordinate measurements and send data to a nutrition API for detailed nutritional analysis.

The guide provides a complete wiring diagram showing how to connect the load cell to the HX711 amplifier and then to the ESP32-C6, a full parts list, firmware for both the scale node and camera node, and step-by-step assembly instructions. After building and calibrating the scale, users will have a functioning device that returns detailed nutrition information including calories, protein, carbs, fat, fiber, sugar, sodium, and potassium for identified foods.

Wiring diagram

Interactive · read-only
Wiring diagram for AI Nutrition Kitchen Scale

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
Seeed Studio XIAO ESP32-C6ESP32-C6 load-cell controller1Separate ESP32-C6 controller for the load-cell subsystem. Reads HX711 and sends the settled weight to M5Paper Color over ESP-NOW.
Seeed Studio XIAO ESP32S3 SenseESP32-S3 Sense camera controller1Separate ESP32-S3 camera controller above the platter. Captures 800x600 JPEGs and sends them to M5Paper Color over ESP-NOW.
HX711 Load Cell Amplifier3.3 V13.3 V bridge amplifier / 24-bit ADC, physically connected only to the XIAO ESP32-C6 and the load cell. Its bridge terminals are component-to-component connections, not M5Paper interfaces.
1 kg Single-Point Load Cell1 kg1Four-wire 1 kg single-point strain-gauge load cell for the scale platter.

Assembly

6 steps
  1. Mount the 1 kg load cell

    Bolt one end of loadcell_1kg_1 rigidly to the scale base and bolt the opposite end to the platter bracket. The platter must not touch the base, screws, cable, or enclosure at any point; that would bypass the sensing beam and cause incorrect readings.

    • Tip: Route the load-cell cable with a slack loop so the cable cannot pull on the beam.
    • Tip: Keep the entire food/container load within the 1 kg rated capacity.
    • Do not drill through, bend, or clamp the thin flexing section of the load cell.
    • Do not overload the sensor; it can be permanently damaged.
  2. Connect the load cell to the HX711

    Wire loadcell_1kg_1 to hx711_1 one-for-one: E+ to E+, E- to E-, A+ to A+, and A- to A-. Follow the wire labels supplied with your particular load cell rather than relying only on wire colors.

    • Tip: Keep these four wires short and away from USB power cables where practical.
    • Tip: If the output changes in the wrong direction, correct the calibration factor only after confirming the A+/A- wiring labels.
    • Do not connect any load-cell wires to the M5Paper Color.
  3. Wire the HX711 only to the XIAO ESP32-C6

    Connect hx711_1 VCC to xiao_esp32c6_scale_1 3V3, GND to GND, DT/DOUT to D2 (GPIO25), and SCK to D3 (GPIO7). This is the only physical MCU connection for the amplifier.

    • Tip: Use 3.3 V for both supply and digital logic so DOUT never exceeds the C6 input voltage.
    • Tip: Power the C6 through its USB-C port during normal use.
    • Never use the Grove 5 V rail for this HX711 connection.
    • The M5Paper has no wire connection to the HX711 or load cell.
  4. Place and power the camera node

    Attach xiao_esp32s3_sense_camera_1 on a stable gooseneck above the platter, framing the whole food area. Power the Sense board through its own USB-C supply and leave enough slack that the cable does not touch the load-cell platter.

    • Tip: Use diffuse light to avoid strong reflections and shadows on the food.
    • Tip: Aim the camera downward and lock the gooseneck after framing.
    • The camera cable and gooseneck must not bear any part of the platter’s load.
  5. Configure the wireless nodes and controller

    Flash the C6 scale-node and S3 Sense camera-node companion sketches separately, then copy each printed MAC address into the M5Paper include/secrets.h file. Put all three devices on the same 2.4 GHz Wi-Fi channel; ESP-NOW and the M5Paper’s LAN connection must share that radio channel.

    • Tip: The two companion sources are stored as data/xiao_esp32c6_scale_node.ino and data/xiao_esp32s3_sense_camera_node.ino.
    • Tip: Set the M5Paper MAC in each companion source before deploying it.
    • Keep API keys only at the LAN proxy; do not add them to any of the three device firmwares.
  6. Calibrate and use the scale

    With the platter/container empty, press M5Paper button A. The PaperColor commands the C6 to tare and median-filter the weight, commands the Sense camera to capture, then sends the JPEG and grams by plain HTTP to your trusted-LAN proxy.

    • Tip: Adjust CAL_FACTOR in the C6 companion sketch using a known mass before food measurements.
    • Tip: The proxy must return food_name plus calories, protein, carbs, fat, fiber, sugar, sodium, and potassium per 100 g.
    • Plain HTTP is appropriate only on a trusted LAN. Do not expose the proxy to the public internet.

Pin assignments

Board wiring reference
PinConnectionType
EXThx711_1 VCCSeeed Studio XIAO ESP32-C6 3V3power
EXThx711_1 GNDSeeed Studio XIAO ESP32-C6 GNDground
EXThx711_1 DTSeeed Studio XIAO ESP32-C6 D2 / GPIO25digital
EXThx711_1 SCKSeeed Studio XIAO ESP32-C6 D3 / GPIO7digital
EXThx711_1 E+1 kg Single-Point Load Cell E+power
EXThx711_1 E-1 kg Single-Point Load Cell E-ground
EXThx711_1 A+1 kg Single-Point Load Cell A+analog
EXThx711_1 A-1 kg Single-Point Load Cell A-analog
EXTxiao_esp32s3_sense_camera_1 USB-C 5VDedicated USB-C 5 V power supplypower
EXTxiao_esp32s3_sense_camera_1 GNDDedicated USB-C power returnground
EXTxiao_esp32c6_scale_1 ESP-NOWM5Paper Color ESP-NOW radio (wireless)data
EXTxiao_esp32s3_sense_camera_1 ESP-NOWM5Paper Color ESP-NOW radio (wireless)data

Firmware

ESP32
main.cppDeploy to device
#include <Arduino.h>
#include <M5Unified.h>
#include <WiFi.h>
#include <esp_now.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include "secrets.h"

// Protocol shared by all three nodes. The XIAO scale node returns grams;
// the XIAO ESP32-S3 Sense returns an ordered set of JPEG chunks.
enum PacketType : uint8_t { WEIGH_REQUEST = 0xB1, WEIGHT_RESULT = 0xB2, CAPTURE_REQUEST = 0xA1, JPEG_CHUNK = 0xA2 };
struct __attribute__((packed)) WeightPacket { uint8_t type; float grams; };
struct __attribute__((packed)) JpegHeader { uint8_t type; uint16_t sequence; uint16_t total; uint16_t payloadLength; };
struct Nutrition { String name; float calories, protein, carbs, fat, fiber, sugar, sodium, potassium; };


// Forward declarations
void status(const char *line);
void onEspNowReceive(const esp_now_recv_info_t *, const uint8_t *data, int len);
bool sendAndWaitForWeight();
bool sendAndWaitForPhoto();
bool postToProxy(Nutrition &n);
void line(int y, const char *label, float value, const char *unit);
void drawResult(const Nutrition &n);
bool addPeer(const uint8_t *mac);

static constexpr size_t JPEG_CAPACITY = 140000;
static constexpr uint32_t SCALE_TIMEOUT_MS = 12000;
static constexpr uint32_t CAMERA_TIMEOUT_MS = 15000;
uint8_t *jpegData = nullptr;
volatile size_t jpegLength = 0;
volatile uint16_t expectedChunk = 0, expectedTotal = 0;
volatile bool jpegComplete = false, jpegInvalid = false, weightReceived = false;
volatile float measuredGrams = 0;

void status(const char *line) {
  M5.Display.fillScreen(TFT_WHITE);
  M5.Display.setTextColor(TFT_BLACK, TFT_WHITE);
  M5.Display.setTextSize(2); M5.Display.setCursor(15, 25); M5.Display.println("AI Kitchen Scale");
  M5.Display.setTextSize(1); M5.Display.setCursor(15, 75); M5.Display.println(line);
}

void onEspNowReceive(const esp_now_recv_info_t *, const uint8_t *data, int len) {
  if (len < 1) return;
  if (data[0] == WEIGHT_RESULT && len == (int)sizeof(WeightPacket)) {
    WeightPacket p; memcpy(&p, data, sizeof(p));
    measuredGrams = p.grams; weightReceived = isfinite(p.grams); return;
  }
  if (data[0] != JPEG_CHUNK || len < (int)sizeof(JpegHeader) || jpegInvalid || !jpegData) return;
  JpegHeader h; memcpy(&h, data, sizeof(h));
  if (h.payloadLength + sizeof(h) != (uint16_t)len || h.sequence != expectedChunk ||
      (expectedTotal && h.total != expectedTotal) || jpegLength + h.payloadLength > JPEG_CAPACITY) { jpegInvalid = true; return; }
  if (!expectedTotal) expectedTotal = h.total;
  memcpy(jpegData + jpegLength, data + sizeof(h), h.payloadLength);
  jpegLength += h.payloadLength; ++expectedChunk;
  if (expectedChunk == expectedTotal) jpegComplete = true;
}

bool sendAndWaitForWeight() {
  weightReceived = false;
  uint8_t request = WEIGH_REQUEST;
  if (esp_now_send(XIAO_SCALE_MAC, &request, 1) != ESP_OK) return false;
  uint32_t started = millis();
  while (!weightReceived && millis() - started < SCALE_TIMEOUT_MS) { M5.update(); delay(5); }
  return weightReceived;
}

bool sendAndWaitForPhoto() {
  jpegData = (uint8_t *)ps_malloc(JPEG_CAPACITY);
  if (!jpegData) jpegData = (uint8_t *)malloc(JPEG_CAPACITY);
  if (!jpegData) return false;
  jpegLength = 0; expectedChunk = 0; expectedTotal = 0; jpegComplete = false; jpegInvalid = false;
  uint8_t request = CAPTURE_REQUEST;
  if (esp_now_send(XIAO_CAMERA_MAC, &request, 1) != ESP_OK) return false;
  uint32_t started = millis();
  while (!jpegComplete && !jpegInvalid && millis() - started < CAMERA_TIMEOUT_MS) { M5.update(); delay(5); }
  return jpegComplete && !jpegInvalid && jpegLength > 100;
}

bool postToProxy(Nutrition &n) {
  String boundary = "----KitchenScaleBoundary";
  String prefix = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"grams\"\r\n\r\n" + String(measuredGrams, 1) +
    "\r\n--" + boundary + "\r\nContent-Disposition: form-data; name=\"image\"; filename=\"food.jpg\"\r\nContent-Type: image/jpeg\r\n\r\n";
  String suffix = "\r\n--" + boundary + "--\r\n";
  size_t length = prefix.length() + jpegLength + suffix.length();
  uint8_t *body = (uint8_t *)ps_malloc(length);
  if (!body) body = (uint8_t *)malloc(length);
  if (!body) return false;
  memcpy(body, prefix.c_str(), prefix.length());
  memcpy(body + prefix.length(), jpegData, jpegLength);
  memcpy(body + prefix.length() + jpegLength, suffix.c_str(), suffix.length());
  HTTPClient http; http.begin(PROXY_URL);
  http.addHeader("Content-Type", "multipart/form-data; boundary=" + boundary);
  int responseCode = http.POST(body, length); free(body);
  if (responseCode != HTTP_CODE_OK) { http.end(); return false; }
  JsonDocument doc; DeserializationError err = deserializeJson(doc, http.getString()); http.end();
  if (err) return false;
  n.name = doc["food_name"] | doc["name"] | "Unknown food";
  JsonObject per100 = doc["per_100g"].as<JsonObject>();
  float multiplier = measuredGrams / 100.0f;
  auto nutrient = [&](const char *key) { return (per100[key] | doc[key] | 0.0f) * multiplier; };
  n.calories=nutrient("calories"); n.protein=nutrient("protein"); n.carbs=nutrient("carbs"); n.fat=nutrient("fat");
  n.fiber=nutrient("fiber"); n.sugar=nutrient("sugar"); n.sodium=nutrient("sodium"); n.potassium=nutrient("potassium");
  return true;
}

void line(int y, const char *label, float value, const char *unit) { M5.Display.setCursor(210, y); M5.Display.printf("%s: %.1f %s", label, value, unit); }
void drawResult(const Nutrition &n) {
  M5.Display.fillScreen(TFT_WHITE);
  M5.Display.drawJpg(jpegData, jpegLength, 10, 14, 190, 145);
  M5.Display.setTextColor(TFT_BLACK, TFT_WHITE); M5.Display.setTextSize(2); M5.Display.setCursor(210, 18); M5.Display.println(n.name);
  M5.Display.setTextSize(1); M5.Display.setCursor(210, 55); M5.Display.printf("Weight: %.1f g", measuredGrams);
  line(85,"Calories",n.calories,"kcal"); line(107,"Protein",n.protein,"g"); line(129,"Carbs",n.carbs,"g"); line(151,"Fat",n.fat,"g");
  line(173,"Fiber",n.fiber,"g"); line(195,"Sugar",n.sugar,"g"); line(217,"Sodium",n.sodium,"mg"); line(239,"Potassium",n.potassium,"mg");
  M5.Display.setCursor(10, 575); M5.Display.println("Press A for the next measurement.");
}

bool addPeer(const uint8_t *mac) {
  esp_now_peer_info_t peer = {}; memcpy(peer.peer_addr, mac, 6); peer.channel = 0; peer.encrypt = false;
  return esp_now_is_peer_exist(mac) || esp_now_add_peer(&peer) == ESP_OK;
}
void setup() {
  auto cfg = M5.config(); M5.begin(cfg); M5.Display.setRotation(0); status("Connecting to Wi-Fi...");
  WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  uint32_t started = millis(); while (WiFi.status() != WL_CONNECTED && millis() - started < 15000) delay(250);
  if (WiFi.status() != WL_CONNECTED) { status("Wi-Fi failed. Check secrets.h."); return; }
  if (esp_now_init() != ESP_OK || !addPeer(XIAO_SCALE_MAC) || !addPeer(XIAO_CAMERA_MAC)) { status("ESP-NOW peer setup failed."); return; }
  esp_now_register_recv_cb(onEspNowReceive);
  status("Ready. Empty the platter/container, then press A.");
}
void loop() {
  M5.update(); if (!M5.BtnA.wasPressed()) { delay(20); return; }
  status("Taring and measuring on the XIAO C6...");
  if (!sendAndWaitForWeight()) { status("Scale node failed. Check C6 power, MAC, and Wi-Fi channel."); return; }
  status("Capturing on the XIAO S3 Sense...");
  if (!sendAndWaitForPhoto()) { status("Camera failed. Check S3 Sense power, MAC, and Wi-Fi channel."); if (jpegData) { free(jpegData); jpegData=nullptr; } return; }
  status("Sending JPEG and grams to LAN proxy..."); Nutrition n = {};
  if (!postToProxy(n)) { status("Proxy failed. Check PROXY_URL and JSON response."); free(jpegData); jpegData=nullptr; return; }
  drawResult(n); free(jpegData); jpegData=nullptr;
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

Project files

Shared by the author

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik