A synchronous protobuf-on-files database for Arduino-style firmware; uses LoFS for filesystem access
LoDB provides CRUD operations with powerful SELECT queries supporting filtering, sorting, and limiting, all using Protocol Buffers for data serialization and onboard file storage.
- Synchronous Design: All operations complete immediately with results returned directly
- Protocol Buffers: Type-safe data storage using nanopb for efficient serialization
- CRUD Operations: Create, Read, Update, and Delete records with simple API calls
- Powerful Queries: SELECT with filtering, sorting, and limiting in a single operation
- Deterministic UUIDs: Generate consistent UUIDs from strings or auto-generate unique ones
- Thread-safe FS access: Operations go through LoFS, which takes
spiLockper call - Filesystem-based: Records under
/lodb/...by default, or/internal/lodb/...//sd/lodb/...when you pick a filesystem explicitly - Memory Efficient: Designed for resource-constrained embedded systems
Add LoDB to lib_deps. It depends on LoFS, nanopb, and rweather/Crypto (see library.json). Your firmware still supplies your table schemas (.proto / generated *_pb.h for your app). The library ships pre-generated nanopb headers under include/lodb/ for the optional lodb.DiagnosticsTest type (diagnostics.pb.h); the matching diagnostics.pb.c is compiled from src/.
lib_deps =
https://github.com/MeshEnvy/lodb.gitOptional logging (define before including <lodb/LoDB.h>):
#define LODB_LOG_DEBUG(...) LOG_DEBUG(__VA_ARGS__)
#define LODB_LOG_INFO(...) LOG_INFO(__VA_ARGS__)
#define LODB_LOG_WARN(...) LOG_WARN(__VA_ARGS__)
#define LODB_LOG_ERROR(...) LOG_ERROR(__VA_ARGS__)Override weak lodb_now_ms() if you need wall time for auto-UUIDs (default: millis()).
Regenerating shipped nanopb (when editing src/diagnostics.proto): run vendor/lodb/scripts/regen_nanopb.sh (requires Python package nanopb).
Public includes (no src/ in the path):
#include <lodb/LoDB.h>
#include <lodb/diagnostics.pb.h> // optional: shipped diagnostics message
#include <lodb/diagnostics.h> // optional: declares lodb_diagnostics()Define the schema:
syntax = "proto3";
message User {
string username = 1;
bytes password_hash = 2;
uint64 uuid = 3;
}Create a matching .options file for nanopb:
User.username max_size:32
User.password_hash max_size:32
Note: Generate nanopb headers for your own .proto files with the nanopb generator (or your firmware’s existing protobuf pipeline).
Initialize the database:
#include <lodb/LoDB.h>
#include "myschema.pb.h"
// Default: legacy `/lodb/...` on internal flash (see LoDB constructor docs)
LoDb *db = new LoDb("myapp");
// Or explicitly specify filesystem
LoDb *dbInternal = new LoDb("myapp", LoFS::FSType::INTERNAL);
LoDb *dbSD = new LoDb("myapp", LoFS::FSType::SD);
db->registerTable("users", &User_msg, sizeof(User));Perform CRUD operations:
User user = User_init_zero;
strncpy(user.username, "alice", sizeof(user.username) - 1);
user.uuid = lodb_new_uuid("alice", myNodeId);
LoDbError err = db->insert("users", user.uuid, &user);
User loadedUser = User_init_zero;
err = db->get("users", user.uuid, &loadedUser);
loadedUser.some_field = new_value;
err = db->update("users", user.uuid, &loadedUser);
err = db->deleteRecord("users", user.uuid);Each record is one .pr file named with a 16-character hex UUID. Layout depends on the LoFS::FSType you pass to LoDb:
LoFS::FSType::AUTO(default):/lodb/<database>/<table>/<uuid>.pr(unprefixed path; LoFS routes to internal flash — matches older single-partition layouts).LoFS::FSType::INTERNAL:/internal/lodb/<database>/…LoFS::FSType::SD:/sd/lodb/<database>/…when SD is available; otherwise LoDB falls back to/internal/lodb/…with a warning.
/lodb/myapp/
└── items/
├── a1b2c3d4e5f67890.pr
└── 1234567890abcdef.pr
Filesystem calls use LoFS, which acquires LockGuard(spiLock) inside each LoFS::open / mkdir / remove (and related) call. LoDB does not take spiLock itself, to avoid deadlocking with LoFS’s non-recursive mutex.
- Deterministic:
lodb_new_uuid("key", salt)— SHA-256 of string + salt, first 8 bytes asuint64_t. - Auto:
lodb_new_uuid(nullptr, salt)— uses weaklodb_now_ms()(defaultmillis()) plus randomness; overridelodb_now_ms()in your firmware if you need wall time. - Filenames:
lodb_uuid_to_hex()→ 16 hex chars +.pr.
Register a table (your nanopb message User from your own .proto):
LoDb db("myapp");
db.registerTable("users", &User_msg, sizeof(User));Deterministic id + get:
lodb_uuid_t id = lodb_new_uuid("alice", nodeSalt);
User u = User_init_zero;
if (db.get("users", id, &u) == LODB_OK) { /* ... */ }Select with filter, sort, limit:
auto active = [](const void *r) -> bool {
return ((const User *)r)->active;
};
auto byName = [](const void *a, const void *b) -> int {
return strcmp(((const User *)a)->name, ((const User *)b)->name);
};
auto rows = db.select("users", active, byName, 10);
// ... use rows ...
LoDb::freeRecords(rows);Read–modify–write:
User u = User_init_zero;
if (db.get("users", uuid, &u) != LODB_OK) return;
u.score = 42;
db.update("users", uuid, &u);For bring-up, you can call lodb_diagnostics() (see <lodb/diagnostics.h>). It uses the shipped lodb.DiagnosticsTest type (<lodb/diagnostics.pb.h>) to exercise CRUD, select, count, truncate, and drop against temporary databases under /lodb, /internal/lodb, and /sd/lodb.
typedef uint64_t lodb_uuid_t;64-bit unsigned integer used as record identifier.
Error codes returned by database operations:
typedef enum {
LODB_OK = 0, // Success
LODB_ERR_NOT_FOUND, // UUID doesn't exist
LODB_ERR_IO, // Filesystem error
LODB_ERR_DECODE, // Protobuf decode failed
LODB_ERR_ENCODE, // Protobuf encode failed
LODB_ERR_INVALID // Invalid parameters
} LoDbError;typedef std::function<bool(const void *)> LoDbFilter;Filter function for SELECT queries. Returns true to include a record in results.
Example:
auto filter = [targetUserId](const void *rec) -> bool {
const Message *msg = (const Message *)rec;
return msg->user_id == targetUserId;
};typedef std::function<int(const void *, const void *)> LoDbComparator;Comparator function for sorting SELECT results. Returns:
-1ifa < b0ifa == b1ifa > b
Example:
auto comparator = [](const void *a, const void *b) -> int {
const User *u1 = (const User *)a;
const User *u2 = (const User *)b;
return strcmp(u1->name, u2->name);
};#define LODB_UUID_FMT "%08x%08x"
#define LODB_UUID_ARGS(uuid) (uint32_t)((uuid) >> 32), (uint32_t)((uuid) & 0xFFFFFFFF)Use these for printf-style UUID formatting on platforms without %llx support:
LOG_INFO("UUID: " LODB_UUID_FMT, LODB_UUID_ARGS(uuid));
// Output: UUID: a1b2c3d4e5f67890lodb_uuid_t lodb_new_uuid(const char *str, uint64_t salt);Generate or derive a UUID.
Parameters:
str: String to hash into UUID, orNULLfor auto-generatedsalt: Salt value (typically node ID), or0for none
Returns: 64-bit UUID
Behavior:
- If
strisNULL: Generates unique UUID from timestamp + random value - If
stris provided: Generates deterministic UUID viaSHA256(str + salt)
Examples:
// Auto-generated UUID (unique)
lodb_uuid_t uuid = lodb_new_uuid(NULL, 0);
// Deterministic UUID for lookups (same inputs = same UUID)
lodb_uuid_t userUuid = lodb_new_uuid("alice", myNodeId);void lodb_uuid_to_hex(lodb_uuid_t uuid, char hex_out[17]);Convert UUID to 16-character hex string.
Parameters:
uuid: UUID to converthex_out: Buffer for hex string (must be at least 17 bytes)
Example:
char hex[17];
lodb_uuid_to_hex(uuid, hex);
printf("UUID: %s\n", hex); // UUID: a1b2c3d4e5f67890LoDb(const char *db_name, LoFS::FSType filesystem = LoFS::FSType::AUTO);Create a new database instance with namespace db_name.
Parameters:
db_name: Database name (directory under the LoDB root for the chosen filesystem mode).filesystem:AUTO(default):/lodb/<db_name>/on internal flash (legacy layout).INTERNAL:/internal/lodb/<db_name>/SD:/sd/lodb/<db_name>/when SD is available; otherwise/internal/lodb/<db_name>/with a warning.
Examples:
LoDb *db = new LoDb("myapp");
LoDb *dbInternal = new LoDb("myapp", LoFS::FSType::INTERNAL);
LoDb *dbSD = new LoDb("myapp", LoFS::FSType::SD);LoDbError registerTable(const char *table_name,
const pb_msgdesc_t *pb_descriptor,
size_t record_size);Register a table with protobuf schema.
Parameters:
table_name: Table name (directory name)pb_descriptor: Nanopb message descriptor (e.g.,&User_msg)record_size: Size of struct (e.g.,sizeof(User))
Returns: LODB_OK on success, error code otherwise
Example:
db->registerTable("users", &User_msg, sizeof(User));LoDbError insert(const char *table_name,
lodb_uuid_t uuid,
const void *record);Insert a new record with specified UUID.
Parameters:
table_name: Name of tableuuid: UUID for this recordrecord: Pointer to protobuf record
Returns:
LODB_OKon successLODB_ERR_INVALIDif UUID already exists or table not registered- Other error codes for filesystem/encoding issues
Example:
User user = User_init_zero;
strncpy(user.username, "alice", sizeof(user.username) - 1);
lodb_uuid_t uuid = lodb_new_uuid("alice", nodeId);
LoDbError err = db->insert("users", uuid, &user);LoDbError get(const char *table_name,
lodb_uuid_t uuid,
void *record_out);Retrieve a record by UUID.
Parameters:
table_name: Name of tableuuid: UUID of record to retrieverecord_out: Buffer to store decoded record (must be at leastrecord_sizebytes)
Returns:
LODB_OKon successLODB_ERR_NOT_FOUNDif UUID doesn't exist- Other error codes for filesystem/decoding issues
Example:
User user = User_init_zero;
lodb_uuid_t uuid = lodb_new_uuid("alice", nodeId);
LoDbError err = db->get("users", uuid, &user);
if (err == LODB_OK) {
printf("Username: %s\n", user.username);
}LoDbError update(const char *table_name,
lodb_uuid_t uuid,
const void *record);Update an existing record by UUID.
Parameters:
table_name: Name of tableuuid: UUID of record to updaterecord: Pointer to updated protobuf record
Returns:
LODB_OKon successLODB_ERR_NOT_FOUNDif UUID doesn't exist- Other error codes for filesystem/encoding issues
Example:
User user = User_init_zero;
db->get("users", uuid, &user);
user.some_field = new_value;
db->update("users", uuid, &user);LoDbError deleteRecord(const char *table_name,
lodb_uuid_t uuid);Delete a record by UUID.
Parameters:
table_name: Name of tableuuid: UUID of record to delete
Returns:
LODB_OKon successLODB_ERR_NOT_FOUNDif UUID doesn't exist
Example:
LoDbError err = db->deleteRecord("users", uuid);std::vector<void *> select(const char *table_name,
LoDbFilter filter = LoDbFilter(),
LoDbComparator comparator = LoDbComparator(),
size_t limit = 0);Query records with optional filtering, sorting, and limiting.
Operation Order: FILTER → SORT → LIMIT
Parameters:
table_name: Name of table to queryfilter: Optional filter function (default: select all)comparator: Optional comparator for sorting (default: no sorting)limit: Optional result limit (default: 0 = no limit)
Returns: Vector of heap-allocated record pointers
Memory Management: Caller must free records using freeRecords() or manually with delete[] (uint8_t *)rec
Examples:
// Select all users
auto allUsers = db->select("users");
// Select with filter
auto filter = [](const void *rec) -> bool {
const User *u = (const User *)rec;
return u->age >= 18;
};
auto adults = db->select("users", filter);
// Select with filter and sort
auto comparator = [](const void *a, const void *b) -> int {
const User *u1 = (const User *)a;
const User *u2 = (const User *)b;
return strcmp(u1->name, u2->name);
};
auto sortedAdults = db->select("users", filter, comparator);
// Select with filter, sort, and limit (top 10)
auto top10 = db->select("users", filter, comparator, 10);
// Process results
for (auto *ptr : top10) {
const User *user = (const User *)ptr;
// ... use user
}
// Free memory
LoDb::freeRecords(top10);static void freeRecords(std::vector<void *> &records);Helper method to free all records in a vector returned by select().
Parameters:
records: Vector of record pointers to free (will be cleared after freeing)
Example:
auto results = db->select("users");
// ... use results ...
LoDb::freeRecords(results); // Frees all records and clears vectorint count(const char *table_name, LoDbFilter filter = LoDbFilter());Count records in a table with optional filtering.
Parameters:
table_name: Name of the table to countfilter: Optional filter function (default: count all records)
Returns: Number of matching records, or -1 on error
Performance:
- If no filter is provided, efficiently counts files without loading records
- If a filter is provided, records are loaded and filtered (less efficient)
Examples:
// Count all users
int totalUsers = db->count("users");
// Count with filter
auto filter = [](const void *rec) -> bool {
const User *u = (const User *)rec;
return u->age >= 18;
};
int adultUsers = db->count("users", filter);
if (totalUsers >= 0) {
LOG_INFO("Total users: %d, Adults: %d", totalUsers, adultUsers);
}LoDbError truncate(const char *table_name);Delete all records from a table but keep the table registered.
Parameters:
table_name: Name of the table to truncate
Returns:
LODB_OKon successLODB_ERR_INVALIDif table not registered
Example:
// Clear all records from users table
LoDbError err = db->truncate("users");
if (err == LODB_OK) {
LOG_INFO("Truncated users table");
}
// Table is still registered, can insert new records
User newUser = User_init_zero;
db->insert("users", uuid, &newUser);LoDbError drop(const char *table_name);Delete all records and unregister the table. The table must be re-registered before use.
Parameters:
table_name: Name of the table to drop
Returns:
LODB_OKon successLODB_ERR_INVALIDif table not registered
Example:
// Drop the users table completely
LoDbError err = db->drop("users");
if (err == LODB_OK) {
LOG_INFO("Dropped users table");
}
// Table is unregistered - must re-register before use
db->registerTable("users", &User_msg, sizeof(User));Lambdas can capture variables from enclosing scope for dynamic filtering:
// Capture multiple variables
uint32_t minAge = 18;
uint32_t maxAge = 65;
const char *country = "USA";
auto filter = [minAge, maxAge, country](const void *rec) -> bool {
const User *u = (const User *)rec;
return u->age >= minAge &&
u->age <= maxAge &&
strcmp(u->country, country) == 0;
};
auto results = db->select("users", filter);Sort by multiple criteria:
auto comparator = [](const void *a, const void *b) -> int {
const Message *m1 = (const Message *)a;
const Message *m2 = (const Message *)b;
// Primary sort: unread messages first
if (!m1->read && m2->read) return -1;
if (m1->read && !m2->read) return 1;
// Secondary sort: newest first (reverse timestamp order)
if (m2->timestamp > m1->timestamp) return 1;
if (m2->timestamp < m1->timestamp) return -1;
return 0;
};
auto messages = db->select("mail", LoDbFilter(), comparator);Always free records returned by select():
auto results = db->select("users");
// Use results
for (auto *ptr : results) {
const User *user = (const User *)ptr;
processUser(user);
}
// Clean up - CRITICAL!
LoDb::freeRecords(results);Implement upsert (insert or update) logic:
bool upsertUser(LoDb *db, lodb_uuid_t uuid, const User *user) {
// Try to get existing record
User existing = User_init_zero;
LoDbError err = db->get("users", uuid, &existing);
if (err == LODB_OK) {
// Record exists, update it
return db->update("users", uuid, user) == LODB_OK;
} else if (err == LODB_ERR_NOT_FOUND) {
// Record doesn't exist, insert it
return db->insert("users", uuid, user) == LODB_OK;
}
// Other error
return false;
}Use count() to efficiently get the number of records:
// Count all records (efficient - doesn't load records)
int totalUsers = db->count("users");
// Count with filter (loads and filters records)
auto activeFilter = [](const void *rec) -> bool {
const User *u = (const User *)rec;
return u->active;
};
int activeUsers = db->count("users", activeFilter);
LOG_INFO("Total: %d users, %d active", totalUsers, activeUsers);Implement pagination for large result sets:
const size_t PAGE_SIZE = 10;
std::vector<void *> getPage(LoDb *db, size_t pageNum) {
// Select all with sort
auto all = db->select("messages", LoDbFilter(), myComparator);
// Calculate offset
size_t offset = pageNum * PAGE_SIZE;
// Extract page
std::vector<void *> page;
for (size_t i = offset; i < all.size() && i < offset + PAGE_SIZE; i++) {
page.push_back(all[i]);
}
// Free records not in page
for (size_t i = 0; i < all.size(); i++) {
if (i < offset || i >= offset + PAGE_SIZE) {
delete[] (uint8_t *)all[i];
}
}
return page;
}- Arduino-style build (as used by PlatformIO
framework = arduino) - nanopb 0.4.9+ (pulled via
library.jsonwhen you depend on LoDB as a PlatformIO library, or supplied by your firmware) - LoFS and rweather/Crypto (also listed in
library.json)
MIT License - see LICENSE file for details.