TinyC Language Reference¶
TinyC is a subset of C that compiles to bytecode for a stack-based virtual machine. It runs both in the browser (JavaScript VM) and on ESP32/ESP8266 (as Tasmota driver XDRV_124).
Table of Contents¶
- Data Types
- Literals
- Variables & Scope
- Operators
- Control Flow
- Functions
- Callback Functions
- Tasmota System Variables
- Arrays
- Strings
- Preprocessor
- Comments
- Type Casting
- enum
- Structs
- typedef
- const Keyword
- static Local Variables
- do-while Loop
- Ternary Operator
- Built-in Functions
- Multi-VM Slots (ESP32)
- VM Limits
- Device File Management (IDE)
- Keyboard Shortcuts (IDE)
- Examples
Data Types¶
| Type | Size | Description |
|---|---|---|
int |
32-bit | Signed integer |
float |
32-bit | IEEE 754 floating-point |
char |
8-bit | Unsigned character (masked to 0xFF) |
bool |
32-bit | Boolean (0 = false, non-zero = true) |
void |
— | No value (function return type) |
Type Aliases¶
| Alias | Maps to |
|---|---|
int32_t |
int |
uint32_t |
int |
unsigned int |
int |
uint8_t |
char |
Literals¶
Integer Literals¶
Float Literals¶
Character Literals¶
Supported escape sequences: \n \t \r \\ \' \" \0
String Literals¶
String literals are used forchar array initialization and as arguments to string functions.
Boolean Literals¶
Variables & Scope¶
Global Variables¶
Declared outside any function. Accessible from all functions.
Persistent Variables¶
Global variables declared with the persist keyword are automatically saved to flash and restored on program restart. This is equivalent to p: variables in Tasmota Scripter.
persist float totalEnergy = 0.0; // saved/restored across reboots
persist int bootCount; // scalar — 4 bytes in file
persist char deviceName[32]; // array — 32 slots in file
- Only global variables can be
persist(not local variables or function parameters) - Persist variables are automatically loaded from a
.pvsfile (derived from the.tcbfilename, e.g./weather.pvsfor/weather.tcb) when the program starts - Persist variables are automatically saved when the program is stopped (
TinyCStop) - Call
saveVars()to manually save at any time (e.g., after midnight counter updates) - Maximum 32 persist entries per program
- Binary format — compact and fast (raw int32 values, floats stored as bit-cast int32)
- File stored on user filesystem (same as
.tcbfiles)
persist float dval = 0.0;
persist float mval = 0.0;
void EverySecond() {
if (tasm_hour == 0 && last_hr != 0) {
dval = smlGet(2); // update daily counter
saveVars(); // save immediately
}
}
Watch Variables (Change Detection)¶
Global variables declared with the watch keyword automatically track changes. Every write saves the old value as a shadow, enabling change detection — essential for IOT monitoring scenarios.
- Only scalar globals can be
watch(int, float — not arrays or locals) - Every write automatically saves the previous value and sets a written flag
- Uses 2 extra global slots per watch variable (shadow + written flag)
Intrinsic functions:
| Function | Returns | Description |
|---|---|---|
changed(var) |
int |
1 if current value differs from shadow |
delta(var) |
int/float |
current - shadow (signed difference) |
written(var) |
int |
1 if variable was assigned since last snapshot() |
snapshot(var) |
void |
set shadow = current, clear written flag |
watch float power;
void EverySecond() {
power = sensorGet("ENERGY#Power");
if (changed(power)) {
float diff = delta(power);
// react to power change
snapshot(power); // acknowledge change
}
}
Local Variables¶
Declared inside functions or blocks. Block-scoped (new scope per { }).
void myFunc() {
int x = 10; // local to myFunc
if (x > 5) {
int y = 20; // local to this block
}
// y is not accessible here
}
Function Parameters¶
Passed by value for scalars, by reference for arrays.
Operators¶
Arithmetic¶
| Op | Description | Types |
|---|---|---|
+ |
Addition | int, float, char[] |
- |
Subtraction | int, float |
* |
Multiplication | int, float |
/ |
Division | int, float |
% |
Modulo | int only |
- |
Unary negation | int, float |
Note: For char[] variables, + performs string concatenation (see Strings).
Comparison¶
| Op | Description |
|---|---|
== |
Equal |
!= |
Not equal |
< |
Less than |
> |
Greater than |
<= |
Less than or equal |
>= |
Greater or equal |
Logical¶
| Op | Description |
|---|---|
&& |
Logical AND (short-circuit) |
\|\| |
Logical OR (short-circuit) |
! |
Logical NOT |
Bitwise¶
| Op | Description |
|---|---|
& |
AND |
\| |
OR |
^ |
XOR |
~ |
NOT |
<< |
Left shift |
>> |
Right shift |
Assignment¶
| Op | Description |
|---|---|
= |
Assign (for char[]: string copy) |
+= |
Add and assign (for char[]: string append) |
-= |
Subtract and assign |
*= |
Multiply and assign |
/= |
Divide and assign |
%= |
Modulo and assign (int only) |
&= |
Bitwise AND and assign |
\|= |
Bitwise OR and assign |
^= |
Bitwise XOR and assign |
<<= |
Left shift and assign |
>>= |
Right shift and assign |
Increment / Decrement¶
Operator Precedence (highest to lowest)¶
- Postfix:
x++x--a[i]f()(type) - Unary:
++x--x-x!x~x - Multiplicative:
*/% - Additive:
+- - Shift:
<<>> - Relational:
<><=>= - Equality:
==!= - Bitwise AND:
& - Bitwise XOR:
^ - Bitwise OR:
| - Logical AND:
&& - Logical OR:
|| - Assignment:
=+=-=*=/=%=&=|=^=<<=>>= - Ternary:
? :
Control Flow¶
if / else¶
if (condition) {
// ...
}
if (condition) {
// ...
} else {
// ...
}
if (a > 0) {
// ...
} else if (a == 0) {
// ...
} else {
// ...
}
while Loop¶
do-while Loop¶
The body executes at least once before the condition is checked:
int i = 0;
do {
process(i);
i++;
} while (i < 10);
// Body runs once even if condition is initially false:
do {
init();
} while (0);
for Loop¶
switch / case¶
Note: Cases fall through unlessbreak is used (like standard C).
break / continue¶
break;— exit the innermost loop or switchcontinue;— skip to the next iteration of the innermost loop
Functions¶
Declaration¶
Entry Point¶
Every program must have a main() function:
Recursion¶
Fully supported:
Array Parameters¶
Arrays are passed by reference:
Callback Functions¶
TinyC supports callback functions that Tasmota calls automatically at specific events. Simply define functions with these well-known names — no registration needed.
Available Callbacks¶
| Function | Tasmota Hook | When Called | Use Case |
|---|---|---|---|
EveryLoop() |
FUNC_LOOP | Every main loop iteration (~1–5 ms) | Ultra-fast polling, bit-banging, time-critical I/O |
Every50ms() |
FUNC_EVERY_50_MSECOND | Every 50 ms (20x/sec) | Fast polling, radio receive, sensor sampling |
Every100ms() |
FUNC_EVERY_100_MSECOND | Every 100 ms (10x/sec) | Medium-rate polling, display updates, debouncing |
EverySecond() |
FUNC_EVERY_SECOND | Every 1 second | Periodic tasks, counters, slow polling |
JsonCall() |
FUNC_JSON_APPEND | Telemetry cycle (~300s) | Add JSON to MQTT telemetry |
WebPage() |
FUNC_WEB_ADD_MAIN_BUTTON | Page load (once) | Charts, custom HTML, scripts |
WebCall() |
FUNC_WEB_SENSOR | Web page refresh (~1s) | Add sensor rows to Tasmota web UI |
WebUI() |
AJAX /tc_ui refresh | Every 2s + on widget change | Interactive widget dashboard (buttons, sliders, etc.) |
UdpCall() |
UDP packet received | On each multicast variable | Process incoming UDP variables |
WebOn() |
Custom HTTP endpoint | On request to webOn() URL |
REST APIs, JSON endpoints, webhooks |
TaskLoop() |
FreeRTOS task (ESP32) | Continuous loop in own task | Background processing, independent of main thread |
CleanUp() |
FUNC_SAVE_BEFORE_RESTART | Before device restart | Close files, flush data, release resources |
TouchButton(btn, val) |
Touch event | On GFX button/slider touch | Handle touch button presses and slider changes |
HomeKitWrite(dev, var, val) |
HomeKit write | When Apple Home changes a value | Control lights, switches, outlets from Apple Home |
Command(char cmd[]) |
Custom console command | When user types registered prefix in console | Handle custom Tasmota commands (e.g., MP3Play, MP3Stop) |
Event(char cmd[]) |
Tasmota event rule trigger | On Event command from rules or console |
React to Tasmota rule events |
OnExit() |
Script stop | When VM is stopped or script replaced | Close serial ports, release resources |
OnMqttConnect() |
FUNC_MQTT_INIT | MQTT broker connected | Subscribe topics, publish status |
OnMqttDisconnect() |
mqtt_disconnected flag | MQTT broker disconnected | Set offline state, stop publishing |
OnMqttData(char topic[], char payload[]) |
FUNC_MQTT_DATA | Message arrives on a subscribed topic | Handle remote commands, ingest sensor data |
OnInit() |
First FUNC_NETWORK_UP | Once after first WiFi connect | One-time init: start services, subscribe MQTT |
OnWifiConnect() |
FUNC_NETWORK_UP | WiFi/network connected (every time) | Reconnect handling |
OnWifiDisconnect() |
FUNC_NETWORK_DOWN | WiFi/network lost | Pause network-dependent tasks |
OnTimeSet() |
FUNC_TIME_SYNCED | NTP time synchronized | Schedule time-based actions |
Execution Model¶
main()runs first in a FreeRTOS task (ESP32) —delay()works as real blocking delay- After main halts, globals and heap persist — they are NOT freed
- Tasmota periodically calls your callbacks, which can read/modify globals
- Callbacks run synchronously with an instruction limit — no
delay()allowed - If
TaskLoop()is defined, it runs in the same FreeRTOS task after main() halts —delay()works, runs independently of Tasmota's main thread
Tasmota Output Functions¶
Use these functions in callbacks to send data to Tasmota:
| Function | Description | Use In |
|---|---|---|
responseAppend(buf) |
Append char array to JSON telemetry (→ ResponseAppend_P) |
JsonCall() |
responseAppend("literal") |
Append string literal to JSON telemetry | JsonCall() |
webSend(buf) |
Send char array to web page (→ WSContentSend) |
WebPage() / WebCall() / WebOn() |
webSend("literal") |
Send string literal to web page | WebPage() / WebCall() / WebOn() |
webFlush() |
Flush web content buffer to client (→ WSContentFlush) |
WebPage() / WebCall() / WebOn() |
webSendFile("filename") |
Send file contents from filesystem to web page | WebPage() / WebCall() / WebUI() / WebOn() |
addCommand("prefix") |
Register custom console command prefix (e.g., "MP3" → MP3Play, MP3Stop) |
main() |
responseCmnd(buf) |
Send char array as console command response | Command() |
responseCmnd("literal") |
Send string literal as console command response | Command() |
Web Page Format¶
Use Tasmota's {s} {m} {e} macros in webSend() to create table rows:
- {s} — start row (label column)
- {m} — middle (value column)
- {e} — end row
Example: "{s}Temperature{m}25.3 °C{e}" renders as a labeled row on the web page.
JSON Telemetry Format¶
Use responseAppend() to add JSON fragments. Start with a comma:
- ",\"Sensor\":{\"Temp\":25}" appends to the telemetry JSON
Example¶
int counter = 0;
void EverySecond() {
counter++;
}
void JsonCall() {
// Appends to Tasmota MQTT telemetry JSON
char buf[64];
sprintf(buf, ",\"TinyC\":{\"Count\":%d}", counter);
responseAppend(buf);
}
void WebCall() {
// Adds a row to the Tasmota web page
char buf[64];
sprintf(buf, "{s}TinyC Counter{m}%d{e}", counter);
webSend(buf);
}
int main() {
counter = 0;
return 0;
}
Result: After uploading and running, the Tasmota web page shows a "TinyC Counter" row that increments every second, and MQTT telemetry includes ,"TinyC":{"Count":N}.
Custom Console Commands¶
Scripts can register custom Tasmota console commands using addCommand("prefix"). When a user types e.g. MP3Play Sound.mp3 in the console, Tasmota matches the prefix "MP3", extracts the subcommand "PLAY SOUND.MP3", and calls Command("PLAY SOUND.MP3") on the script.
Note: Tasmota uppercases the command topic, so subcommands arrive as "PLAY", "STOP", etc. Data after a space (filenames, numbers) keeps its original case.
int volume = 15;
void Command(char cmd[]) {
char buf[64];
if (strFind(cmd, "PLAY") == 0) {
// handle play
responseCmnd("Playing");
} else if (strFind(cmd, "STOP") == 0) {
responseCmnd("Stopped");
} else if (strFind(cmd, "VOL") == 0) {
char arg[16];
strSub(arg, cmd, 4, 0); // extract everything after "VOL "
volume = atoi(arg);
sprintf(buf, "Volume: %d", volume);
responseCmnd(buf);
} else {
responseCmnd("Unknown: Play|Stop|Vol");
}
}
int main() {
addCommand("MP3"); // register "MP3" prefix
return 0;
}
Result: Typing MP3Play in the Tasmota console calls Command("PLAY"), typing MP3Vol 20 calls Command("VOL 20").
TaskLoop Example (ESP32)¶
int counter = 0;
void TaskLoop() {
counter++;
char buf[64];
sprintf(buf, "TaskLoop count=%d", counter);
addLog(buf); // appears in Tasmota console log
delay(1000); // real 1-second delay, doesn't block Tasmota
}
void JsonCall() {
char buf[64];
sprintf(buf, ",\"TinyC\":{\"Count\":%d}", counter);
responseAppend(buf);
}
int main() {
addLog("TaskLoop demo starting");
return 0;
}
Result: TaskLoop() runs independently in a FreeRTOS task, incrementing the counter every second. JsonCall() reports the counter in MQTT telemetry. Both run concurrently — the mutex ensures safe VM access.
Important Notes¶
- Callbacks must be fast — max 200,000 instructions (ESP32) / 20,000 (ESP8266) per invocation
- No
delay()in callbacks (capped at 100ms if called) — exceptTaskLoop()which supports real delays main()must return (not loop forever) for callbacks to activate- Only the eight well-known names above are recognized
- The compiler auto-detects these function names and embeds them in the binary
EveryLoop()runs every main loop iteration (~1–5 ms) — keep it very short to avoid blocking TasmotaEvery50ms()is ideal for fast, non-blocking I/O polling (SPI radio, GPIO, etc.)Every100ms()suits display refreshes, button debouncing, and medium-rate sensor reads- Use
WebPage()for one-time page content (charts, scripts) — called once when page loads - Use
WebCall()for sensor-style rows that refresh periodically - Use
UdpCall()to process incoming UDP multicast variables TaskLoop()runs in a dedicated FreeRTOS task (ESP32 only) — can usedelay()freely, VM access is mutex-serialized with main-thread callbacks
Dynamic Task Spawn (ESP32)¶
Beyond the fixed TaskLoop(), you can launch up to 4 additional background tasks on demand by name. Each spawned task shares the calling VM's state — globals, heap, constants — and runs concurrently with main(), callbacks, and TaskLoop(). Ideal replacements for one-shot timers, delayed jobs, or long background workers.
| Function | Description |
|---|---|
spawnTask(char name[]) |
Start a FreeRTOS task that calls name(). Returns pool slot 0..3, or -1 on error. Default stack 5 KB |
spawnTask(char name[], int stack_kb) |
Same, but with custom stack size (clamped 3..16 KB) |
killTask(char name[]) |
Cooperative stop: sets a flag the task observes at next instruction or delay() boundary. Returns 0 if signaled, -1 if not running |
taskRunning(char name[]) |
Returns 1 if a task with that name is active on this slot, 0 otherwise |
Name must be a string literal — the compiler enforces this and registers the target in the bytecode function table at compile time. Dynamic names (variables/expressions) are not supported. A literal that doesn't resolve to a user-defined function is a compile-time error.
Stack sizing guide (default 5 KB works for most workers):
- 3 KB — absolute minimum, only safe if the worker uses no addLog / sprintf* / VM syscalls beyond basic arithmetic and delay()
- 5 KB (default) — trivial workers with addLog + delay loops
- 6–8 KB — httpGet over HTTP (plain), small JSON parsing
- 10–16 KB — httpGet over HTTPS/TLS, large JSON parsing, complex worker pipelines
Semantics:
- Shared VM: spawned tasks see and mutate the same globals/heap as
main(). Use this for worker jobs that update global state. - One name per slot: a second
spawnTask("foo")whilefoois still running returns -1. UsekillTask("foo")+taskRunning("foo")poll first. - Cooperative kill:
killTaskis non-blocking. The task will self-terminate at its next instruction boundary or after the currentdelay()wakes up. Usewhile (taskRunning("foo")) delay(10);to wait. - Mutex discipline: spawnTasks honor the same mutex as
TaskLoop().delay()inside a spawn task releases the mutex so other tasks and callbacks can run. - Auto-cleanup: when the script stops (TinyCStop) all spawned tasks are signaled and given 2 s to exit.
- No arguments: the spawned function takes no parameters and its return value is ignored.
Example — one-shot delayed job:
void Blinker() {
for (int i = 0; i < 5; i++) {
gpioWrite(2, 1); delay(200);
gpioWrite(2, 0); delay(200);
}
}
void Command(char s[]) {
if (strcmp(s, "BLINK") == 0) {
if (taskRunning("Blinker")) {
addLog("Blinker already active");
} else {
spawnTask("Blinker");
}
}
}
int main() { return 0; }
Typing TinyCCmd BLINK in the console spawns the blinker without blocking the console. A second TinyCCmd BLINK while blinking is refused.
Example — parallel background downloader:
char url[] = "http://example.com/data.json";
int download_done = 0;
char body[2048];
void Downloader() {
int rc = httpGet(url, body, sizeof(body));
download_done = (rc > 0) ? 1 : -1;
}
void EverySecond() {
if (download_done == 1) {
addLog("download ok");
download_done = 0;
} else if (download_done == -1) {
addLog("download failed");
download_done = 0;
}
}
int main() {
spawnTask("Downloader", 6); // 6 KB stack for HTTPS
return 0;
}
Example — killable worker:
int worker_ticks = 0;
void Worker() {
while (1) {
worker_ticks++;
delay(500);
}
}
void Command(char s[]) {
if (strcmp(s, "START") == 0 && !taskRunning("Worker")) spawnTask("Worker");
if (strcmp(s, "STOP") == 0) killTask("Worker");
}
int main() { return 0; }
Limits:
- Max 4 concurrent spawned tasks per device (shared pool across all VM slots)
- Function name max 23 chars
- Stack 2..12 KB, default 3 KB — bump to 6+ for HTTPS / JSON / large buffers
- ESP8266: all four calls return -1 (not supported)
Tasmota System Variables¶
TinyC provides virtual tasm_* variables that read/write Tasmota system state directly. They are used like normal variables — no function calls needed. The compiler translates them to syscalls automatically.
Available Variables¶
| Variable | Type | R/W | Description |
|---|---|---|---|
tasm_wifi |
int | read | WiFi status (1 = connected, 0 = disconnected) |
tasm_mqttcon |
int | read | MQTT connection status (1 = connected) |
tasm_teleperiod |
int | read/write | Telemetry period in seconds (10–3600, clamped) |
tasm_uptime |
int | read | Device uptime in seconds |
tasm_heap |
int | read | Free heap memory in bytes |
tasm_power |
int | read/write | Relay power state (bitmask, write toggles relay) |
tasm_dimmer |
int | read/write | Dimmer level 0–100 (write sends Dimmer command) |
tasm_temp |
float | read | Temperature from Tasmota sensor (global TempRead()) |
tasm_hum |
float | read | Humidity from Tasmota sensor (global HumRead()) |
tasm_hour |
int | read | Current hour (0–23, from RTC) |
tasm_minute |
int | read | Current minute (0–59, from RTC) |
tasm_second |
int | read | Current second (0–59, from RTC) |
tasm_year |
int | read | Current year (e.g. 2026, from RTC) |
tasm_month |
int | read | Current month (1–12, from RTC) |
tasm_day |
int | read | Day of month (1–31, from RTC) |
tasm_wday |
int | read | Day of week (1=Sun, 2=Mon, … 7=Sat) |
tasm_cw |
int | read | ISO calendar week (1–53) |
tasm_sunrise |
int | read | Sunrise, minutes since midnight (requires USE_SUNRISE) |
tasm_sunset |
int | read | Sunset, minutes since midnight (requires USE_SUNRISE) |
tasm_time |
int | read | Current time, minutes since midnight |
tasm_pheap |
int | read | Free PSRAM in bytes (ESP32 only, 0 on ESP8266) |
tasm_smlj |
int | read/write | SML JSON output enable/disable (requires USE_SML_M) |
tasm_npwr |
int | read | Number of power (relay) devices |
Indexed Tasmota State Functions¶
| Function | Description |
|---|---|
int tasmPower(int index) |
Power state of relay index (0-based). Returns 0 or 1 |
int tasmSwitch(int index) |
Switch state (0-based, Switch1 = index 0). Returns -1 if invalid |
int tasmCounter(int index) |
Pulse counter value (0-based, Counter1 = index 0). Requires USE_COUNTER |
Tasmota String Info¶
int tasmInfo(int sel, char buf[]) — fills buf with a Tasmota info string. Returns string length.
| sel | Content |
|---|---|
| 0 | MQTT topic |
| 1 | MAC address |
| 2 | Local IP address |
| 3 | Friendly name |
| 4 | Device name |
| 5 | MQTT group topic |
| 6 | Reset reason (string) |
Example:
Usage¶
// Read system state
if (tasm_wifi) {
printStr("WiFi connected\n");
}
// Read sensor values (float)
float t = tasm_temp;
float h = tasm_hum;
// Read real-time clock
int h = tasm_hour; // 0–23
int m = tasm_minute; // 0–59
int s = tasm_second; // 0–59
int y = tasm_year; // e.g. 2026
int mo = tasm_month; // 1–12
int d = tasm_day; // 1–31
int wd = tasm_wday; // 1=Sun..7=Sat
int cw = tasm_cw; // ISO calendar week 1–53
// Sunrise/sunset automation
int now = tasm_time; // minutes since midnight
if (now > tasm_sunset || now < tasm_sunrise) {
tasm_power = 1; // night — turn on light
}
// Write system state
tasm_teleperiod = 60; // set telemetry to 60 seconds
tasm_power = 1; // turn relay ON
tasm_dimmer = 50; // set dimmer to 50%
Notes¶
- No declaration needed —
tasm_*names are recognized by the compiler automatically - No global slot used — they don't consume global variable space
- Read-only enforcement — writing to read-only variables (e.g.,
tasm_wifi = 1) gives a compile-time error - Float type inference —
tasm_tempandtasm_humare correctly typed asfloatin expressions - Write side-effects —
tasm_powerexecutesPowercommand,tasm_dimmerexecutesDimmercommand,tasm_teleperiodupdates Tasmota's Settings directly - In the browser IDE, all variables return simulated values
Example — Auto Power Control¶
void EverySecond() {
// Turn off relay if temperature too high
if (tasm_temp > 30.0) {
tasm_power = 0;
}
// Report via web
char buf[64];
sprintf(buf, "{s}Temp{m}%.1f C{e}", tasm_temp);
webSend(buf);
}
int main() {
tasm_teleperiod = 30; // fast telemetry for testing
return 0;
}
Arrays¶
Declaration & Initialization¶
int data[10]; // uninitialized
int primes[5] = {2, 3, 5, 7, 11}; // with initializer
float values[3] = {1.5, 2.5}; // partial init
char name[32] = "TinyC"; // string init (null-terminated)
char greeting[] = "Hello World"; // size inferred from string (12)
int flags[] = {1, 0, 1, 1}; // size inferred from initializer (4)
When the size is omitted ([]), the compiler infers it automatically:
- String initializer: size = string length + 1 (for null terminator)
- Array initializer: size = number of elements
Access¶
Scope¶
- Small arrays (≤16 elements) — stored inline in global data or local frame (fast direct access)
- Large arrays (>16 elements) — automatically allocated on the VM heap
Array Memory¶
Arrays with up to 16 elements are stored inline in the global or local frame for fast direct access. Arrays with more than 16 elements are automatically routed to the VM heap by the compiler — no special syntax needed:
int rgb[3]; // inline (3 ≤ 16) — fast direct access
char buf[128]; // heap (128 > 16) — automatic allocation
float data[2000]; // heap (2000 > 16)
int main() {
rgb[0] = 255; // direct frame access
buf[0] = 'H'; // heap access — same syntax
data[1999] = 3.14; // heap access
return 0;
}
Both inline and heap arrays support all the same operations: element access, string operations on char[], passing to functions, etc.
Heap limits:
Heap limits:
| Platform | Max Heap Slots | Max Handles |
|---|---|---|
| ESP8266 | 2,048 (8 KB) | 8 |
| ESP32 | 8,192 (32 KB) | 16 |
| Browser | 16,384 (64 KB) | 32 |
Strings¶
Strings in TinyC are char arrays with null termination.
Declaration¶
String Assignment & Concatenation with +¶
The = and += operators work on char[] variables for intuitive string handling:
char buf[64];
char name[16] = "World";
// Assign string literal or char array
buf = "Hello"; // same as strcpy(buf, "Hello")
buf = name; // same as strcpy(buf, name)
// Append with +=
buf += " "; // same as strcat(buf, " ")
buf += name; // same as strcat(buf, name)
// Concatenate with +
buf = buf + "!"; // same as strcat(buf, "!")
buf = buf + name; // same as strcat(buf, name)
Note: The + operator only works when the left side of = is the same variable as the left side of + (i.e., buf = buf + ...). Cross-variable concatenation like a = b + c is not supported — use strcpy + strcat for that.
Built-in String Functions¶
int len = strlen(greeting); // length (excluding \0)
strcpy(buffer, greeting); // copy array to array
strcpy(buffer, "World"); // copy literal to array
strcat(buffer, greeting); // append array
strcat(buffer, "!"); // append literal
int cmp = strcmp(greeting, buffer); // compare: -1, 0, or 1
printString(greeting); // print string to output
Formatted String Output (sprintf)¶
sprintf supports multiple values in a single call. The compiler auto-detects each value's type from the format specifier:
char buf[128];
int id = 1;
float temp = 23.5;
char name[] = "sensor";
// Multiple values in one call:
sprintf(buf, "id=%d temp=%.1f name=%s", id, temp, name);
// buf = "id=1 temp=23.5 name=sensor"
// Single value also works as before:
sprintf(buf, "x = %d", 42); // "x = 42"
sprintf(buf, "pi = %.2f", 3.14); // "pi = 3.14"
Building Multi-Value Strings (sprintfAppend)¶
Use sprintfAppend to append multiple values to an existing string:
char report[128];
sprintf(report, "Sensor %d", 1); // "Sensor 1"
sprintfAppend(report, " val=%.1f", 3.14); // "Sensor 1 val=3.1"
printString(report);
| Function | Description |
|---|---|
sprintf(char dst[], "fmt", val, ...) |
Format one or more values into dst (overwrites). Type auto-detected. |
sprintfAppend(char dst[], "fmt", val, ...) |
Format one or more values and append to dst. Type auto-detected. |
Legacy aliases: The explicit-type variants
sprintfInt,sprintfFloat,sprintfStr,sprintfAppendInt,sprintfAppendFloat,sprintfAppendStrare still supported for backward compatibility.
Format specifiers: %d (int), %f %.2f %e %g (float), %s (string).
String Manipulation¶
char src[64] = "hello,world,test";
char dst[32];
// Extract nth token (1-based) by delimiter
int len = strToken(dst, src, ',', 2); // dst = "world", len = 5
// Substring (0-based position, length)
strSub(dst, src, 6, 5); // dst = "world"
strSub(dst, src, -4, 4); // dst = "test" (negative = from end)
// Find substring position (-1 if not found)
int pos = strFind(src, "world"); // pos = 6
int no = strFind(src, "xyz"); // no = -1
| Function | Description |
|---|---|
strToken(char dst[], char src[], int delim, int n) |
Copy nth token (1-based) delimited by char delim into dst. Returns token length. |
strSub(char dst[], char src[], int pos, int len) |
Copy len chars starting at pos (0-based, negative=from end) into dst. len=0 copies to end of string. Returns actual length. |
strFind(char haystack[], char needle[]) |
Find first occurrence of needle in haystack. Returns position (0-based) or -1 if not found. |
int strToInt(char str[]) |
Convert string to integer (like atoi). Returns 0 if not a valid number. |
float strToFloat(char str[]) |
Convert string to float (like atof). Returns 0.0 if not a valid number. |
Array Sort¶
Sort an array in-place:
| Function | Description |
|---|---|
sortArray(int arr[], int count, int flags) |
Sort array in-place. flags: 0=int ascending, 1=float ascending, 2=int descending, 3=float descending |
arrayFill(int arr[], int value, int count) |
Fill first count elements with value |
arrayCopy(int dst[], int src[], int count) |
Copy count elements from src to dst |
int smlCopy(int arr[], int count) |
Copy SML decoder values into float array. Returns number copied (requires USE_SML_M) |
int data[5] = {42, 7, 99, 3, 55};
sortArray(data, 5, 0); // ascending: {3, 7, 42, 55, 99}
sortArray(data, 5, 2); // descending: {99, 55, 42, 7, 3}
arrayFill(data, 0, 5); // zero all elements
Character Access¶
Escape Sequences in Strings¶
| Escape | Character |
|---|---|
\n |
Newline |
\t |
Tab |
\r |
Carriage return |
\\ |
Backslash |
\" |
Double quote |
\' |
Single quote |
\0 |
Null terminator |
\xNN |
Hex character code (e.g. \x41 = 'A') |
Preprocessor¶
#define — Compile-Time Constants¶
Simple compile-time constants (no macro expansion):
Features:
- Value must be a constant expression
- Supports arithmetic on other #define values: +, -, *, /
- Used for array sizes, function arguments, etc.
- Scope: entire program
- Valueless defines allowed for conditionals: #define ESP32
Limitations:
- No #include
Function-Like Macros¶
Parameterized macros perform text substitution before compilation:
Usage:
LOG("sensor init"); // → addLog("sensor init")
int v = CLAMP(reading, 100); // → int v = min(max(reading, 0), 100)
int s = SQUARE(5); // → int s = (5 * 5)
Features:
- Parameters are replaced by whole-word matching (won't replace partial identifiers)
- Nested parentheses in arguments are handled correctly: LOG(foo(1,2)) works
- String literal arguments are preserved: LOG("hello, world") — the comma inside quotes is not treated as an argument separator
- Nested macro expansion: macros in the expanded body are expanded (up to 10 iterations)
- Multiple parameters supported: #define ADD(A, B) (A + B)
Empty body macros — debug stripping:
#define DBG(M) // empty body — no replacement text
DBG("checkpoint 1"); // → stripped entirely (including semicolon)
int x = 42; // this line is unaffected
Empty-body macros remove the entire invocation including the trailing semicolon. This is useful for stripping debug calls in production builds:
#ifdef DEBUG
#define DBG(M) addLog(M)
#else
#define DBG(M)
#endif
DBG("init done"); // logs in debug, stripped in release
Conditional Compilation¶
#define ESP32
#define USE_SENSOR
#ifdef ESP32
int pin = 8; // included — ESP32 is defined
#else
int pin = 2; // excluded
#endif
#ifndef USE_DISPLAY
// included — USE_DISPLAY is not defined
#endif
| Directive | Description |
|---|---|
#define NAME |
Define a name (no value, for conditionals) |
#define NAME value |
Define a name with a constant value |
#define NAME(A) body |
Function-like macro with text substitution |
#undef NAME |
Undefine a previously defined name |
#ifdef NAME |
Include block if NAME is defined |
#ifndef NAME |
Include block if NAME is NOT defined |
#if EXPR |
Include block if expression is non-zero |
#else |
Alternative block |
#endif |
End conditional block |
#if expressions support:
- Integer literals: #if 1, #if 0
- Defined names (1 if defined, 0 if not): #if ESP32
- defined(NAME) operator: #if defined(ESP32)
- Logical operators: &&, ||, !
- Comparison: ==, !=, >, <, >=, <=
- Parentheses for grouping
Notes: - Conditionals can be nested - Skipped code is not compiled (does not need to be valid syntax) - Line numbers in error messages are preserved
Comments¶
Type Casting¶
Explicit Casts¶
float f = 3.14;
int i = (int)f; // truncates to 3
int x = 42;
float y = (float)x; // converts to 42.0
int ch = 321;
char c = (char)ch; // masks to 0xFF → 65 ('A')
int b = (bool)42; // non-zero → 1
Implicit Conversions¶
When mixing int and float in an expression, the int operand is automatically promoted to float:
sizeof Operator¶
sizeof is a compile-time operator that resolves to an integer literal. There is no runtime cost — the compiler folds the value directly into the bytecode.
Forms¶
sizeof(type) // int, float, char, bool, struct Tag, or a typedef name
sizeof(name) // a declared variable, array, or struct
sizeof name // same as above, without parentheses
Sizes (bytes, following C conventions)¶
| Thing | sizeof |
|---|---|
int, float |
4 |
char, bool |
1 |
char buf[40] |
40 |
int arr[10] |
40 |
float ff[5] |
20 |
struct Foo |
sum of member byte sizes |
struct Foo v[N] |
N × sizeof(struct Foo) |
Note: in TinyC's VM every scalar occupies a 32-bit slot internally, but sizeof always reports bytes as a C programmer would expect. sizeof(char) is 1, not 4.
Examples¶
char buf[80];
int arr[10];
int a = sizeof(int); // 4
int b = sizeof(buf); // 80
int n = sizeof(arr) / sizeof(int); // 10 (element count idiom)
struct Frame { int id; char name[8]; float v; };
int s = sizeof(struct Frame); // 16 (4 + 8 + 4)
Use in constant expressions¶
sizeof can appear in array-size expressions since it folds to a constant:
Not supported¶
Workaround: for the element-size of an array use sizeof(type) directly, e.g. sizeof(arr) / sizeof(int).
Ternary Operator¶
The conditional expression condition ? value_if_true : value_if_false:
int abs_val = (x >= 0) ? x : -x;
float clamped = (t > 100.0) ? 100.0 : t;
// Nested ternary
int grade = (score >= 90) ? 3 : (score >= 70) ? 2 : 1;
Result type follows normal int/float promotion rules.
enum¶
Named integer constants expanded at compile time:
// Global enum
enum Color { RED = 0, GREEN = 1, BLUE = 2 };
// Negative values supported
enum Status { OK = 0, WARN = 1, ERR = -1 };
// Auto-increment (starts at 0, or after last explicit value)
enum Day { MON, TUE, WED, THU, FRI, SAT, SUN };
// MON=0, TUE=1, WED=2 ...
// Inline enum inside a function
void process() {
enum Mode { IDLE = 0, RUN = 1, STOP = 2 };
int mode = RUN;
}
- Enum values are treated as
intconstants — identical to#define - The enum tag name is optional:
enum { A, B, C }is valid - No enum type checking — values are plain integers
const Keyword¶
The const qualifier is accepted on variable declarations:
consthas no runtime effect in TinyC — it is a documentation hint only- The variable can technically be written to (no enforcement)
- Accepted on both global and local variables
- Accepted in combination with
static:static const int N = 10;
static Local Variables¶
A local variable declared static is stored in the global data segment but is only accessible by name within its declaring function. Its value persists across function calls — it is initialised to zero when the program starts and retains its value between calls.
// Call counter — value survives across calls
int nextId() {
static int id = 0;
id++;
return id;
}
void main() {
int a = nextId(); // a = 1
int b = nextId(); // b = 2
int c = nextId(); // c = 3
}
- Initialiser value (e.g.
static int n = 5) is not emitted — the variable is always zero-initialised at program start. Set a non-zero initial value explicitly on first call if needed. staticglobal variables behave the same as regular globals (no difference in TinyC)
do-while Loop¶
See Control Flow → do-while Loop above.
Structs¶
A struct groups multiple fields into a single named variable. Each field is a separate VM slot — no padding, no alignment requirements.
Declaration¶
struct Point {
float x;
float y;
};
struct Sensor {
float temperature;
float humidity;
int status;
};
Variable declaration and member access¶
struct Point p; // local struct variable
p.x = 3.14;
p.y = 2.71;
float dist = p.x + p.y;
// Positional initializer list
struct Point origin = {0.0, 0.0};
struct Point corner = {100.0, 200.0};
Global structs¶
struct Sensor g_sensor; // global — persists between callbacks
void EverySecond() {
g_sensor.temperature = sensorGet("DS18B20#Temperature");
g_sensor.status = (g_sensor.temperature > 30.0) ? 1 : 0;
}
Compound member assignment¶
All compound operators work on struct fields:
Array fields in structs¶
A struct field can itself be an array — specify the element count in brackets:
struct Msg {
int id;
char text[32]; // 32-element char array field
};
struct Stats {
int count;
int vals[8]; // int array field
float avg;
};
Element access uses the same obj.field[index] syntax:
struct Msg m;
m.id = 1;
m.text[0] = 'H';
m.text[1] = 'i';
m.text[2] = 0;
// Index from a variable works too
int i;
for (i = 0; i < 8; i++) {
m.text[i] = 65 + i; // 'A'…'H'
}
Compound assignments work on array field elements:
Passing a char array field to string functions — use obj.field (without subscript) as the array reference:
struct Frame {
int seq;
char payload[64];
};
struct Frame f;
strcpy(f.payload, "hello");
sprintf(f.payload, "seq=%d", f.seq);
addLog(f.payload);
Layout: array fields occupy consecutive VM slots immediately after any preceding scalar fields. The total slot count for a struct is the sum of all field sizes (scalar fields = 1 slot each, array fields = arraySize slots).
Struct inside function parameters¶
Structs cannot be passed by value to functions. Pass a scalar field, or use a global.
Notes¶
- Field access
p.xcompiles to an array slot offset — no new VM opcodes - Scalar fields can be
int,float,char,bool - Array fields declared as
type name[N]within the struct body - No nested structs (struct as field type)
- No pointer types
typedef¶
typedef creates a type alias. Used with both primitive types and structs.
Primitive alias¶
typedef int pin_t;
typedef float celsius_t;
typedef int millisec_t;
pin_t led = 5;
celsius_t temp = 23.5;
millisec_t timeout = 1000;
Named struct alias¶
Allows using the type name without the struct keyword:
struct Vec2 { float x; float y; };
typedef struct Vec2 Vec2;
Vec2 v; // no 'struct' prefix needed
v.x = 1.0;
Anonymous struct typedef¶
Define and name a struct in one declaration:
Chained aliases¶
typedef inside functions¶
typedef may appear inside a function body. The alias is visible for the rest of the function.
Built-in Functions¶
Output¶
| Function | Description |
|---|---|
print(int value) |
Print integer + newline |
print("literal") |
Print string literal (auto-detected) |
print(char buf[]) |
Print char array as string (auto-detected) |
printStr("literal") |
Print string literal (explicit) |
printString(char arr[]) |
Print null-terminated char array (explicit) |
Note:
print()auto-detects the argument type. When passed a string literal, it prints the string. When passed achar[]array, it prints the array contents as a string. When passed anint, it prints the numeric value. The explicitprintStr/printStringfunctions are still available but rarely needed.
GPIO¶
| Function | Description |
|---|---|
pinMode(int pin, int mode) |
Set pin mode (1=INPUT, 3=OUTPUT, 5=INPUT_PULLUP, 9=INPUT_PULLDOWN) |
digitalWrite(int pin, int value) |
Write HIGH(1) or LOW(0) |
int digitalRead(int pin) |
Read pin state |
int analogRead(int pin) |
Read analog value (0–4095) |
analogWrite(int pin, int value) |
Write PWM value |
gpioInit(int pin, int mode) |
Release pin from Tasmota + pinMode |
Timing¶
| Function | Description |
|---|---|
delay(int ms) |
Wait milliseconds |
delayMicroseconds(int us) |
Wait microseconds |
int millis() |
Milliseconds since program start |
int micros() |
Microseconds since program start |
Software Timers¶
4 independent countdown timers (IDs 0-3) based on millis(). Timers run independently of callbacks — set a timer in main() or any callback, check it in EveryLoop().
| Function | Description |
|---|---|
timerStart(int id, int ms) |
Start timer id (0-3) with ms millisecond timeout |
int timerDone(int id) |
Returns 1 if timer expired (or never started), 0 if running |
timerStop(int id) |
Cancel timer |
int timerRemaining(int id) |
Milliseconds remaining (0 if expired/stopped) |
Example — repeating timer with timeout:
int counter;
void main() {
counter = 0;
timerStart(0, 5000); // timer 0: every 5 seconds
timerStart(1, 60000); // timer 1: stop after 1 minute
}
void EveryLoop() {
if (timerDone(0)) {
counter++;
print(counter);
timerStart(0, 5000); // restart for next interval
}
if (timerDone(1)) {
timerStop(0); // stop repeating timer
}
}
Serial¶
Up to 3 serial ports can be open simultaneously. serialBegin() returns a handle (0–2) that must be passed to all other serial functions. Returns -1 on failure.
| Function | Description |
|---|---|
int serialBegin(int rx, int tx, int baud, int config, int bufsize) |
Open serial port, returns handle (0–2) or -1 on failure |
serialPrint(int h, "literal") |
Print string to serial port h |
serialPrintInt(int h, int value) |
Print integer to serial port h |
serialPrintFloat(int h, float value) |
Print float to serial port h |
serialPrintln(int h, "literal") |
Print string + newline to serial port h |
int serialRead(int h) |
Read byte from port h (-1 if none available) |
int serialAvailable(int h) |
Bytes available to read on port h |
serialClose(int h) |
Close serial port h |
serialWriteByte(int h, int b) |
Write single byte to serial port h |
serialWrite(int h, char str[]) |
Write char array to serial port h (binary-safe) |
serialWriteBytes(int h, char buf[], int len) |
Write len bytes from buffer to serial port h |
serialBegin parameters:
- rx — GPIO pin for receive (-1 to disable RX, e.g. TX-only devices)
- tx — GPIO pin for transmit (-1 to disable TX, e.g. RX-only devices)
- baud — baud rate (e.g. 9600, 115200)
- config — serial frame format (see table below), default 3 = 8N1
- bufsize — receive buffer size in bytes (64–2048)
Serial config values:
| Value | Format | Value | Format | Value | Format |
|---|---|---|---|---|---|
| 0 | 5N1 | 8 | 5E1 | 16 | 5O1 |
| 1 | 6N1 | 9 | 6E1 | 17 | 6O1 |
| 2 | 7N1 | 10 | 7E1 | 18 | 7O1 |
| 3 | 8N1 | 11 | 8E1 | 19 | 8O1 |
| 4 | 5N2 | 12 | 5E2 | 20 | 5O2 |
| 5 | 6N2 | 13 | 6E2 | 21 | 6O2 |
| 6 | 7N2 | 14 | 7E2 | 22 | 7O2 |
| 7 | 8N2 | 15 | 8E2 | 23 | 8O2 |
Example — single port:
// Open serial for LD2410 radar sensor: RX=pin 16, TX=pin 17, 256000 baud, 8N1, 256 byte buffer
int ser = serialBegin(16, 17, 256000, 3, 256);
if (ser < 0) { addLog("Serial open failed"); }
// TX-only for MP3 module: no RX, TX=pin 4, 9600 baud
int mp3 = serialBegin(-1, 4, 9600, 3, 64);
serialWriteByte(mp3, 0x7E);
Example — two ports simultaneously:
int radar = serialBegin(16, 17, 256000, 3, 256); // handle 0
int gps = serialBegin(18, 19, 9600, 3, 256); // handle 1
void EverySecond() {
while (serialAvailable(gps) > 0) {
int b = serialRead(gps);
// process GPS byte...
}
}
1-Wire¶
| Function | Description |
|---|---|
owSetPin(int pin) |
Set GPIO pin for native 1-Wire bus |
int owReset() |
Send reset pulse, return 1 if presence detected |
owWrite(int byte) |
Write one byte to the bus |
int owRead() |
Read one byte from the bus |
owWriteBit(int bit) |
Write a single bit (0 or 1) |
int owReadBit() |
Read a single bit |
owSearchReset() |
Reset the ROM search state |
int owSearch(char rom[]) |
Find next device, store 8-byte ROM in rom[], return 1 if found |
The native 1-Wire functions use hardware-timed bit-banging in C — no external library needed. Requires a 4.7 kΩ pull-up resistor on the data line. For long buses or noisy environments, use a DS2480B serial-to-1-Wire bridge (see
examples/onewire.tc).
Math¶
| Function | Description |
|---|---|
int abs(int value) |
Absolute value |
int min(int a, int b) |
Minimum of two values |
int max(int a, int b) |
Maximum of two values |
int map(int val, int fLo, int fHi, int tLo, int tHi) |
Map value from one range to another |
int random(int min, int max) |
Random integer in range |
float sqrt(float x) |
Square root |
float sin(float x) |
Sine (radians) |
float cos(float x) |
Cosine (radians) |
float exp(float x) |
Exponential (e^x) |
float log(float x) |
Natural logarithm (ln x) |
float pow(float base, float exp) |
Power (base^exp) |
float acos(float x) |
Inverse cosine (radians) |
float intBitsToFloat(int bits) |
Reinterpret int as IEEE 754 float |
int floor(float x) |
Integer part (round toward −∞) |
int ceil(float x) |
Integer part + 1 (round toward +∞) |
int round(float x) |
Round to nearest integer |
String¶
| Function | Description |
|---|---|
int strlen(char arr[]) |
String length (excluding null) |
strcpy(char dst[], char src[]) |
Copy string |
strcpy(char dst[], "literal") |
Copy literal into array |
strcat(char dst[], char src[]) |
Concatenate string |
strcat(char dst[], "literal") |
Concatenate literal |
int strcmp(char a[], char b[]) |
Compare: returns -1, 0, or 1 |
printString(char arr[]) |
Print string to output |
String operators: char[] variables also support =, +=, and + for string assignment and concatenation — see Strings section.
sprintf — Formatted Strings¶
Format one or more values into a char array in a single call. The compiler auto-detects each value's type from the format specifier and expands multiple arguments into chained syscalls at compile time.
| Function | Description |
|---|---|
int sprintf(char dst[], "fmt", val, ...) |
Format value(s) into dst (overwrites). Type auto-detected. |
int sprintfAppend(char dst[], "fmt", val, ...) |
Format value(s), append to end of dst. Type auto-detected. |
Legacy aliases:
sprintfInt,sprintfFloat,sprintfStr,sprintfAppendInt,sprintfAppendFloat,sprintfAppendStrstill work.
Format specifiers: %d %i %x (int), %f %.Nf %e %g (float), %s (string).
All functions return the total string length.
char buf[128];
char name[] = "sensor";
int id = 1;
float temp = 23.5;
// Multiple values in one call:
sprintf(buf, "id=%d temp=%.1f name=%s", id, temp, name);
// buf = "id=1 temp=23.5 name=sensor"
// sprintfAppend chains onto existing content:
sprintf(buf, "ID=%d", id);
sprintfAppend(buf, " val=%.1f", temp);
// buf = "ID=1 val=23.5"
File I/O¶
Read and write files on the ESP32 filesystem (LittleFS). In the browser IDE, files are simulated in a virtual filesystem.
| Function | Description |
|---|---|
int fileOpen("path", mode) |
Open file, returns handle (0–3) or -1 on error |
int fileClose(handle) |
Close file handle, returns 0 or -1 |
int fileRead(handle, char buf[], max) |
Read up to max bytes into buf, returns count |
int fileWrite(handle, char buf[], len) |
Write len bytes from buf, returns count |
int fileExists("path") |
Check if file exists: 1=yes, 0=no |
int fileDelete("path") |
Delete file, returns 0=ok, -1=error |
int fileSize("path") |
Get file size in bytes, -1 on error |
int fileSeek(handle, offset, whence) |
Seek to position. Returns 1=ok, 0=fail |
int fileTell(handle) |
Get current position in file, -1 on error |
int fsInfo(int sel) |
Filesystem info: sel=0 → total KB, sel=1 → free KB |
int fileOpenDir("path") |
Open directory for listing, returns handle or -1 |
int fileReadDir(handle, char name[]) |
Read next filename into name. Returns 1=entry, 0=end |
File modes: 0 = read, 1 = write (create/truncate), 2 = append
Seek whence: 0 = SEEK_SET (from start), 1 = SEEK_CUR (from current), 2 = SEEK_END (from end)
Notes:
- File paths can be string literals (e.g., "/data.txt") or char[] variables
- Filesystem selection (Scripter-compatible): default is SD card (ufsp). Use /ffs/ prefix for flash, /sdfs/ prefix for SD card explicitly: fileOpen("/ffs/config.txt", 0) opens from flash, fileOpen("/data.txt", 0) opens from SD card
- Maximum 4 files open simultaneously (ESP32), 8 in browser
- Buffer arguments (buf) must be char arrays, not string literals
- fileRead returns the number of bytes actually read (may be less than max)
- Always close files when done to free handles
// Example: Write and read back
char data[32];
char buf[32];
strcpy(data, "Hello!\n");
int f = fileOpen("/test.txt", 1); // write mode
fileWrite(f, data, strlen(data));
fileClose(f);
f = fileOpen("/test.txt", 0); // read mode
int n = fileRead(f, buf, 31);
buf[n] = 0;
fileClose(f);
printString(buf); // prints "Hello!"
fileDelete("/test.txt"); // clean up
// Example: List files in a directory
char fname[64];
int dir = fileOpenDir("/images");
if (dir >= 0) {
while (fileReadDir(dir, fname)) {
printString(fname);
print("\n");
}
fileClose(dir);
}
Directory listing notes:
- fileOpenDir uses a file handle slot (same pool as fileOpen), close with fileClose when done
- fileReadDir returns filenames only (no path prefix), skips subdirectories
- Path argument can be a string literal or a char array variable
Extended File Operations¶
Filesystem management, structured array I/O, and log file rotation.
| Function | Description |
|---|---|
int fileFormat() |
Format LittleFS filesystem (erases all data). Returns 0=ok |
int fileMkdir("path") |
Create directory. Returns 1=ok, 0=fail |
int fileRmdir("path") |
Remove directory. Returns 1=ok, 0=fail |
int fileReadArray(int arr[], handle) |
Read one tab-delimited line into int array. Returns element count |
fileWriteArray(int arr[], handle) |
Write array as tab-delimited line with trailing newline |
fileWriteArray(int arr[], handle, append) |
Write with append flag: 1=omit newline (for appending multiple arrays on one line) |
int fileLog("fname", char str[], limit) |
Append string + newline to file. Remove first line if file exceeds limit bytes. Returns file size |
int fileDownload("fname", char url[]) |
Download URL content to file. Returns HTTP status code (200=ok). Compatible with Scripter's frw() |
int fileGetStr(char dst[], handle, "delim", index, endChar) |
Search file from start for Nth occurrence of delimiter, extract string until endChar. Returns string length. Compatible with Scripter's fcs() |
fileReadArray / fileWriteArray format: Values are stored as decimal text separated by TAB characters, one array per line. This is compatible with Scripter's fra()/fwa() format.
// Example: Save and load array data
int values[5];
values[0] = 100; values[1] = 200; values[2] = 300;
values[3] = 400; values[4] = 500;
int f = fileOpen("/data.tab", 1); // write mode
fileWriteArray(values, f); // writes "100\t200\t300\t400\t500\n"
fileClose(f);
int loaded[5];
f = fileOpen("/data.tab", 0); // read mode
int n = fileReadArray(loaded, f); // n = 5
fileClose(f);
// Example: Rolling log file (max 4096 bytes)
char msg[64];
strcpy(msg, "Sensor reading: 23.5C");
fileLog("/log.txt", msg, 4096);
// Appends line, removes oldest line if file > 4096 bytes
// Example: Download file from web
char url[128];
strcpy(url, "http://192.168.1.100/data.csv");
int status = fileDownload("/data.csv", url);
// status = 200 on success, negative on error
// Example: Extract 2nd comma-delimited field from CSV file
// File content: "name,temperature,humidity\nSensor1,23.5,65\n"
int f = fileOpen("/data.csv", 0); // open for reading
char value[32];
int len = fileGetStr(value, f, ",", 2, '\n');
// value = "23.5", len = 4 (content between 2nd comma and newline)
fileClose(f);
File Data Extract (IoT Time-Series)¶
Extract a time range from tab-delimited CSV data files into float arrays for analysis. Designed for IoT data collectors that log sensor readings at regular intervals.
Data file format: First column is a timestamp (ISO or German locale), followed by tab-separated float values. First line may be a header (auto-skipped).
| Function | Description |
|---|---|
int fileExtract(handle, char from[], char to[], col_offs, accum, int arr1[], ...) |
Extract rows where from <= timestamp <= to. Always seeks from file start. Returns row count |
int fileExtractFast(handle, char from[], char to[], col_offs, accum, int arr1[], ...) |
Same but caches file position for efficient sequential time-range queries |
Parameters:
- handle — open file handle (from fileOpen)
- from, to — timestamp range as char[] (ISO 2024-01-15T12:00:00 or German 15.1.24 12:00)
- col_offs — skip this many data columns before distributing to arrays (0 = start at first data column)
- accum — 0: store values, 1: add to existing array values (for combining multiple extracts)
- arr1, arr2, ... — variable number of int arrays, one per column to extract (up to 16). Values are stored as IEEE 754 float bit patterns — use float variables or casts to read them
// Example: Extract temperature and humidity for one day
int temp[96], hum[96]; // 96 = 24h * 4 (15-min intervals)
char from[24], to[24];
strcpy(from, "15.12.21 00:00");
strcpy(to, "16.12.21 00:00");
int f = fileOpen("/daily.csv", 0);
// col_offs=4 skips WB,WR1,WR2,WR3 → starts at ATMP_a (5th data col)
int rows = fileExtract(f, from, to, 4, 0, temp, hum);
fileClose(f);
// rows = number of 15-min samples, temp[] and hum[] filled with floats
// Example: Sequential daily queries with fileExtractFast
int energy[96];
char from[24], to[24];
int f = fileOpen("/yearly.csv", 0);
strcpy(from, "1.1.24 00:00");
strcpy(to, "2.1.24 00:00");
int r1 = fileExtractFast(f, from, to, 0, 0, energy);
// Next day — fileExtractFast skips already-scanned data
strcpy(from, "2.1.24 00:00");
strcpy(to, "3.1.24 00:00");
int r2 = fileExtractFast(f, from, to, 0, 0, energy);
fileClose(f);
Time / Timestamp Functions¶
Timestamp conversion and arithmetic. Supports ISO web format (2024-01-15T12:30:45) and German locale format (15.1.24 12:30). Compatible with Scripter's tstamp, cts, tso, tsn, s2t.
| Function | Description |
|---|---|
int timeStamp(char buf[]) |
Get current Tasmota local timestamp into buf. Returns 0 |
int timeConvert(char buf[], flg) |
Convert timestamp format in-place. 0=German→Web, 1=Web→German. Returns 0 |
int timeOffset(char buf[], days) |
Add days offset to timestamp in buf (in-place). Returns 0 |
int timeOffset(char buf[], days, zeroFlag) |
With zeroFlag=1: also zero the time portion (HH:MM:SS→00:00:00) |
int timeToSecs(char buf[]) |
Convert timestamp string to epoch seconds. Returns seconds |
int secsToTime(char buf[], secs) |
Convert epoch seconds to ISO timestamp string in buf. Returns 0 |
Format auto-detection: timeConvert and timeOffset auto-detect the input format (ISO if contains T, German otherwise) and preserve or convert accordingly.
// Example: Get current time and convert formats
char ts[24];
timeStamp(ts); // ts = "2024-06-15T14:30:00"
char de[24];
strcpy(de, ts);
timeConvert(de, 1); // de = "15.6.24 14:30"
timeConvert(de, 0); // de = "2024-06-15T14:30:00" (back to web)
// Example: Date arithmetic
char ts[24];
timeStamp(ts); // "2024-06-15T14:30:00"
timeOffset(ts, 7); // "2024-06-22T14:30:00" (+ 7 days)
timeOffset(ts, -3, 1); // "2024-06-19T00:00:00" (- 3 days, zero time)
// Example: Convert to seconds and back
char ts[24];
timeStamp(ts);
int secs = timeToSecs(ts); // epoch seconds
secs = secs + 3600; // add 1 hour
secsToTime(ts, secs); // back to timestamp string
Tasmota Command¶
Execute any Tasmota console command and capture the JSON response.
| Function | Description |
|---|---|
int tasmCmd("command", char response[]) |
Execute command (string literal), store response, return length |
int tasmCmd(char cmd[], char response[]) |
Execute command (char array), store response, return length |
Notes:
- Command can be a string literal (e.g., "Status 0") or a char[] variable for dynamic commands
- Response buffer should be a char array (recommended size: 256)
- Returns length of response string, or -1 on error
- In the browser IDE, returns a simulated mock response
- On ESP32, executes real Tasmota commands and captures the JSON response
char resp[256];
int len = tasmCmd("Status 0", resp);
if (len > 0) {
printString(resp); // prints JSON response
}
Sensor JSON Parsing¶
Read any Tasmota sensor value by its JSON path. Path segments are separated by # (same convention as Tasmota Scripter).
| Function | Description |
|---|---|
float sensorGet("Sensor#Key") |
Read sensor value, returns float |
The function internally triggers a sensor status read and navigates the JSON tree. Supports up to 3 levels of nesting.
// Read BME280 sensor
float temp = sensorGet("BME280#Temperature");
float hum = sensorGet("BME280#Humidity");
float press = sensorGet("BME280#Pressure");
// Read SHT3X on address 0x44
float t = sensorGet("SHT3X_0x44#Temperature");
// Read energy meter (if USE_ENERGY_SENSOR defined)
float power = sensorGet("ENERGY#Power");
float voltage = sensorGet("ENERGY#Voltage");
float today = sensorGet("ENERGY#Today");
// Nested: Zigbee device
float zt = sensorGet("ZbReceived#0x2342#Temperature");
Notes:
- Path must be a string literal (resolved at compile time)
- Returns 0.0 if the sensor or key is not found
- Returns a float — assign to a float variable
- In the browser IDE, simulates Temperature=22.5, Humidity=55.0, Pressure=1013.25
Localized Strings¶
Retrieve Tasmota's localized display strings at runtime. The strings match the firmware's compile-time language setting (e.g. en_GB.h, de_DE.h). Use these for web UI labels; JSON keys stay in English.
| Function | Description |
|---|---|
int LGetString(int index, char dst[]) |
Copy localized string to dst, returns length (0 if invalid index) |
String Index Table:
| Index | Tasmota Define | English |
|---|---|---|
| 0 | D_TEMPERATURE | Temperature |
| 1 | D_HUMIDITY | Humidity |
| 2 | D_PRESSURE | Pressure |
| 3 | D_DEWPOINT | Dew point |
| 4 | D_CO2 | Carbon dioxide |
| 5 | D_ECO2 | eCO2 |
| 6 | D_TVOC | TVOC |
| 7 | D_VOLTAGE | Voltage |
| 8 | D_CURRENT | Current |
| 9 | D_POWERUSAGE | Power |
| 10 | D_POWER_FACTOR | Power Factor |
| 11 | D_ENERGY_TODAY | Energy Today |
| 12 | D_ENERGY_YESTERDAY | Energy Yesterday |
| 13 | D_ENERGY_TOTAL | Energy Total |
| 14 | D_FREQUENCY | Frequency |
| 15 | D_ILLUMINANCE | Illuminance |
| 16 | D_DISTANCE | Distance |
| 17 | D_MOISTURE | Moisture |
| 18 | D_LIGHT | Light |
| 19 | D_SPEED | Speed |
| 20 | D_ABSOLUTE_HUMIDITY | Abs Humidity |
Example:
char lbl[32];
char buf[80];
void web_row(int idx, float val, char unit[]) {
LGetString(idx, lbl);
strcpy(buf, "{s}");
strcat(buf, lbl);
strcat(buf, "{m}");
webSend(buf);
sprintf(buf, "%.1f ", val);
strcat(buf, unit);
strcat(buf, "{e}");
webSend(buf);
}
void WebCall() {
web_row(0, temperature, "°C"); // "Temperature" or localized
web_row(1, humidity, "%"); // "Humidity" or localized
web_row(2, pressure, "hPa"); // "Pressure" or localized
}
Tasmota Output (Callbacks)¶
Send data directly to Tasmota's telemetry and web systems from callback functions.
| Function | Description |
|---|---|
void responseAppend(char buf[]) |
Append string to MQTT JSON telemetry (ResponseAppend_P) |
void responseAppend("literal") |
Append string literal to JSON (no buffer needed) |
void webSend(char buf[]) |
Send string to web page HTML (WSContentSend) |
void webSend("literal") |
Send string literal to web page (no buffer needed) |
void webFlush() |
Flush web content buffer to client (WSContentFlush) |
void addLog(char buf[]) |
Write message to Tasmota log (AddLog at INFO level) |
void addLog("literal") |
Write string literal to Tasmota log |
void addLogLevel(int level, char buf[]) |
Write to Tasmota log at specific level (1=ERROR, 2=INFO, 3=DEBUG, 4=DEBUG_MORE) |
void addLogLevel(int level, "literal") |
Write string literal to Tasmota log at specific level |
webSendJsonArray(float arr[], int count) |
Emit float array as JSON integer array in web response |
Notes:
- addLog, webSend and responseAppend accept either a char array or a string literal
- String literal variants are more efficient — no copy through a buffer, sent directly from constant pool
- Use responseAppend() inside JsonCall() — appends to the MQTT telemetry JSON
- Use webSend() inside WebPage() for one-time page content (charts, scripts, custom HTML)
- Use webSend() inside WebCall() for sensor-style rows that refresh periodically
- Use {s}Label{m}Value{e} format in webSend() for sensor-style table rows
- Call webFlush() periodically when building large HTML pages to flush the chunked transfer buffer (500 bytes)
- Start JSON with comma: ",\"Key\":value" to append correctly to telemetry
- In the browser IDE, both route to the output console; webFlush() is a no-op
- Callback instruction limit: 200,000 (ESP32), 20,000 (ESP8266)
- See Callback Functions for full examples
HTTP Requests¶
Make HTTP GET/POST requests to external APIs. URLs can be string literals or dynamically built in char arrays. Requests are blocking with a 5-second timeout.
| Function | Description |
|---|---|
int httpGet(char url[], char response[]) |
HTTP GET, returns response length or negative error |
int httpPost(char url[], char data[], char response[]) |
HTTP POST, returns response length or negative error |
void httpHeader(char name[], char value[]) |
Set custom header for the next request |
int webParse(char source[], "delim", int index, char result[]) |
Parse non-JSON response text (see below) |
Return values: > 0 = response body length, 0 = empty response, negative = HTTP error code (e.g., -404).
Example — Daikin aircon sensor query:
char url[64];
char response[256];
char token[32];
int len;
int pos;
void main() {
strcpy(url, "http://192.168.188.43/aircon/get_sensor_info");
len = httpGet(url, response);
// response = "ret=OK,htemp=19.0,hhum=-,otemp=7.0,err=0,cmpfreq=0"
if (len > 0) {
// Extract indoor temperature (htemp)
pos = strFind(response, token); // find "htemp="
strToken(token, response, ',', 3); // 3rd token = "htemp=19.0"
printString(token);
}
}
Example — Tasmota command to another device:
char url[128];
char response[512];
int len;
void EverySecond() {
strcpy(url, "http://192.168.1.100/cm?cmnd=Status%200");
len = httpGet(url, response);
if (len > 0) {
print(len);
// parse response with strFind/strToken...
}
}
Example — POST with custom header:
char url[128];
char data[128];
char hname[32];
char hval[64];
char response[512];
void main() {
strcpy(url, "http://192.168.1.100/api/data");
strcpy(data, "{\"value\":42}");
strcpy(hname, "Content-Type");
strcpy(hval, "application/json");
httpHeader(hname, hval); // set header before request
int len = httpPost(url, data, response);
}
webParse() — Parse non-JSON web responses
Equivalent to Scripter's gwr(). Extracts data from plain-text HTTP responses (key=value, CSV, line-based formats).
Two modes:
- index > 0 — Split source by delim, return the Nth segment (1-based). Returns length.
- index < 0 — Find delim=value pattern, extract value (stops at ,, :, or NUL). Returns length.
- index == 0 — No-op, returns 0.
Example — Daikin aircon with webParse:
char url[64];
char response[256];
char value[32];
void main() {
strcpy(url, "http://192.168.188.43/aircon/get_sensor_info");
int len = httpGet(url, response);
// response = "ret=OK,htemp=19.0,hhum=-,otemp=7.0,err=0,cmpfreq=0"
if (len > 0) {
// name=value mode: extract value after "htemp="
webParse(response, "htemp", -1, value); // value = "19.0"
float temp = atof(value);
print(temp); // 19.0
// split mode: get 4th comma-separated field
webParse(response, ",", 4, value); // value = "otemp=7.0"
printString(value);
}
}
TCP Server¶
Start a TCP stream server to accept incoming connections. Only one client is served at a time.
| Function | Description |
|---|---|
int tcpServer(int port) |
Start TCP server on port. Returns 0=ok, -1=fail, -2=no network |
tcpClose() |
Close TCP server and disconnect client |
int tcpAvailable() |
Accept pending client and return bytes available to read |
int tcpRead(char buf[]) |
Read string from TCP client into buf. Returns bytes read |
tcpWrite(char str[]) |
Write string to TCP client |
int tcpReadArray(int arr[]) |
Read available bytes into int array (one byte per element). Returns count |
tcpWriteArray(int arr[], int num) |
Write num array elements as uint8 bytes to TCP client |
tcpWriteArray(int arr[], int num, int type) |
Write with type: 0=uint8, 1=uint16 BE, 2=sint16 BE, 3=float BE |
Example — Simple TCP echo server:
char buf[128];
void main() {
tcpServer(8888); // listen on port 8888
}
void Every50ms() {
int n = tcpAvailable(); // accept client + check available
if (n > 0) {
tcpRead(buf); // read incoming string
tcpWrite(buf); // echo it back
}
}
Example — Binary data streaming:
int data[100];
void main() {
tcpServer(9000);
}
void EverySecond() {
int n = tcpAvailable();
if (n > 0) {
// read raw bytes into array
int count = tcpReadArray(data);
print(count);
// send back as uint16 big-endian
tcpWriteArray(data, count, 1);
}
}
TCP Client¶
Open outgoing TCP connections to remote hosts. Up to 4 parallel client slots are supported; a selector picks the active slot, and all read/write calls operate on that slot. Slot 0 additionally falls back to the server-accepted client from tcpServer(), so the same tcpRead/tcpWrite/tcpAvailable API works for both roles.
| Function | Description |
|---|---|
int tcpConnect("host", port) |
Open a TCP connection from the active slot to host:port. Returns 0=connected, -1=fail, -2=no network |
int tcpConnect(char host[], port) |
Same, with a char-array host (IP or DNS name) instead of a literal |
int tcpConnected() |
Returns 1 if the active slot has an open connection, 0 otherwise |
tcpDisconnect() |
Close the active slot's client connection |
tcpSelect(int slot) |
Select the active client slot (0–3). All subsequent client calls target this slot |
Notes:
- tcpRead(buf), tcpWrite(buf), tcpAvailable(), tcpReadArray(), tcpWriteArray() all operate on the active slot. Call tcpSelect(n) to switch.
- tcpWrite() still requires a char[] — string literals are not accepted (declare char msg[] = "hello\n"; tcpWrite(msg);).
- Slot 0 is special: if no outgoing client is open on slot 0, it transparently falls back to the server-side client from tcpServer(). This lets existing server-only scripts keep working unchanged.
- Connections are non-blocking-ish but have a short socket-level timeout — a failed tcpConnect() returns quickly with -1.
Example — Periodic TCP client sending a heartbeat:
char rxbuf[128];
char msg[] = "ping\n";
void EverySecond() {
tcpSelect(0); // active slot = 0
if (!tcpConnected()) {
int r = tcpConnect("192.168.1.50", 1234);
if (r != 0) { return; } // retry next tick
}
tcpWrite(msg);
delay(150); // give server a beat to reply
if (tcpAvailable() > 0) {
int n = tcpRead(rxbuf);
print(n); // e.g. 24 bytes echoed back
}
}
void OnExit() {
tcpDisconnect(); // clean up on script stop
}
Example — Two independent TCP clients in parallel:
char buf[128];
char hello[] = "hello\n";
void main() {
tcpSelect(0);
tcpConnect("10.0.0.10", 9000); // slot 0 → metrics server
tcpSelect(1);
tcpConnect("10.0.0.11", 9001); // slot 1 → command server
}
void EverySecond() {
// Push heartbeat on slot 0
tcpSelect(0);
if (tcpConnected()) { tcpWrite(hello); }
// Poll replies on slot 1
tcpSelect(1);
if (tcpConnected() && tcpAvailable() > 0) {
tcpRead(buf);
// dispatch command in buf...
}
}
MQTT Subscribe / Publish¶
Subscribe to MQTT topics and react to inbound messages, or publish arbitrary payloads. Requires USE_MQTT in the firmware build (enabled by default).
| Function | Description |
|---|---|
int mqttSubscribe("topic") |
Subscribe to topic. Returns the subscription slot (0–9) on success, -1 on failure (no free slot, broker down) |
int mqttSubscribe(char topic[]) |
Same, with a char-array topic (runtime-built) |
int mqttUnsubscribe("topic") |
Unsubscribe from a previously subscribed topic. Returns 0=ok, -1=not found |
mqttPublish("topic", "payload") |
Publish payload to topic (both literals or char arrays accepted) |
Notes:
- Up to 10 subscriptions per VM, topic max 128 chars.
- Wildcard '#' is supported as a trailing prefix match only ("sensors/#" matches sensors/temp, sensors/humi/1, etc.). MQTT's + single-level wildcard is not supported.
- Matching topics trigger the OnMqttData(char topic[], char payload[]) callback. The two strings are copied into the VM heap for the duration of the callback.
- Subscriptions persist across TinyCRun reloads of the same slot. Call mqttUnsubscribe() in OnExit() if you want a clean slate on restart.
- Subscriptions are automatically re-sent to the broker on reconnect (hooked into FUNC_MQTT_INIT).
Example — Remote control via MQTT:
char reply[64];
void main() {
mqttSubscribe("cmnd/room1/#"); // wildcard prefix
mqttSubscribe("home/heartbeat"); // exact match
}
void OnMqttData(char topic[], char payload[]) {
if (strcmp(topic, "home/heartbeat") == 0) {
mqttPublish("stat/room1/alive", "ok");
return;
}
// cmnd/room1/light → toggle GPIO etc.
sprintf(reply, "got %s = %s", topic, payload);
addLogLevel(2, reply);
}
void OnExit() {
mqttUnsubscribe("cmnd/room1/#");
mqttUnsubscribe("home/heartbeat");
}
mDNS Service Advertisement¶
Register the device as an mDNS service on the local network, enabling device emulation (Everhome ecotracker, Shelly, or custom services).
| Function | Description |
|---|---|
int mdnsRegister("name", "mac", "type") |
Start mDNS responder and advertise service. Returns 0 on success |
Parameters (all string literals):
- name — hostname prefix. Use "-" for Tasmota's default hostname, or a custom prefix (MAC is appended automatically)
- mac — MAC address. Use "-" for device's own MAC (lowercase, no colons), or provide a custom string
- type — service type: "everhome" (ecotracker), "shelly", or any custom service name
Built-in emulation types:
- "everhome" — registers _everhome._tcp with IP, serial, productid TXT records
- "shelly" — registers _http._tcp and _shelly._tcp with firmware metadata TXT records
- Any other string — registers _<type>._tcp with IP and serial TXT records
Example — Everhome ecotracker emulation:
This is equivalent to Scripter's mdnsRegister("ecotracker-", "-", "everhome").
WebUI Widgets¶
Create interactive dashboards using widget functions. Widgets can appear in two places:
- Dedicated
/tc_uipage — use theWebUI()callback - Tasmota main page (sensor section) — use the
WebCall()callback
Both callbacks use the same widget functions.
| Function | Description |
|---|---|
webButton(var, "label") |
Toggle button (0/1) — displays ON/OFF, click toggles |
webSlider(var, min, max, "label") |
Range slider — drag to set value |
webCheckbox(var, "label") |
Checkbox (0/1) — check/uncheck toggles |
webText(chararray, maxlen, "label") |
Text input — edit string variable |
webNumber(var, min, max, "label") |
Number input with min/max bounds |
webPulldown(var, "label", "opt0\|opt1\|opt2") |
Dropdown select with label — pipe-separated options, 0-based index. Use "@getfreepins" as options to show available GPIO pins |
webRadio(var, "opt0\|opt1\|opt2") |
Radio button group — pipe-separated options, 0-based index |
webTime(var, "label") |
Time picker (HH:MM) — stored as HHMM integer (e.g., 1430 = 14:30) |
webPageLabel(page, "label") |
Register page 0–5 with a button label on the main page |
int webPage() |
Returns current page number being rendered (use in WebUI() to branch) |
webConsoleButton("/url", "label") |
Register button in Tasmota Utilities menu (max 4). Navigates to URL on click |
The first argument of widget functions is always a global variable that the widget reads from and writes to. The compiler automatically passes the variable's address to the syscall.
Example — Widgets on the main page:
int relay;
int brightness;
void WebCall() {
webButton(relay, "Power");
webSlider(brightness, 0, 100, "Brightness");
}
Example — Multiple pages with custom buttons:
Up to 6 pages can be registered with webPageLabel(). Each creates a button on the Tasmota main page. Use webPage() inside WebUI() to render different widgets per page.
int power;
int brightness;
int mode;
int alarm_time;
char devname[32];
void WebUI() {
int page = webPage();
if (page == 0) {
webButton(power, "Power");
webSlider(brightness, 0, 100, "Brightness");
webPulldown(mode, "Mode", "Off|Auto|Manual");
}
if (page == 1) {
webTime(alarm_time, "Wake-up Time");
webText(devname, 32, "Device Name");
}
}
int main() {
webPageLabel(0, "Controls"); // first button on main page
webPageLabel(1, "Settings"); // second button on main page
return 0;
}
If no webPageLabel() is called but WebUI() exists, a single "TinyC UI" button appears.
How it works:
1. WebCall() renders widgets in the sensor section of the Tasmota main page
2. WebUI() renders widgets on dedicated pages at http://<device>/tc_ui?p=N
3. webPageLabel(N, "text") registers page N (0–5) with a button on the main page
4. webPage() returns the current page number so WebUI() can show different widgets
5. When you move a slider / click a button, JavaScript sends the new value via AJAX
6. The server writes the value directly into the TinyC global variable
7. The page auto-refreshes to show updated state
8. Text and number inputs pause auto-refresh while you're editing (resumes on blur)
WebChart — Automatic Google Charts¶
WebChart() renders Google Charts on the Tasmota main page with a single function call per data series. It automatically loads the Google Charts library and generates all required JavaScript.
void WebChart(int type, "title", "unit", int color, int pos, int count,
float array[], int decimals, int interval, float ymin, float ymax)
| Parameter | Description |
|---|---|
type |
Chart type: 0 = line chart, 1 = column chart |
"title" |
Chart title (string literal). Empty "" = add series to previous chart |
"unit" |
Y-axis unit label (string literal, e.g. "°C", "%", "m/s") |
color |
Line/bar color as hex RGB (e.g. 0xe74c3c for red) |
pos |
Current write position in the ring buffer |
count |
Number of valid data points (≤ array size) |
array |
Float array containing the data (ring buffer) |
decimals |
Number of decimal places for data values (0–6) |
interval |
Minutes between data points (for X-axis time labels) |
ymin |
Y-axis minimum. If ymin >= ymax, chart auto-scales |
ymax |
Y-axis maximum. If ymin >= ymax, chart auto-scales |
Example — 24h weather charts:
#define NPTS 288 // 24h at 5-min intervals
persist float h_temp[NPTS];
persist float h_hum[NPTS];
persist int h_pos = 0;
persist int h_count = 0;
void WebPage() {
if (h_count < 1) return;
WebChart(0, "Temperature", "\u00b0C", 0xe74c3c, h_pos, h_count, h_temp, 1, 5, -20, 50);
WebChart(0, "Humidity", "%", 0x3498db, h_pos, h_count, h_hum, 1, 5, 0, 100);
}
Chart size: Use webChartSize(width, height) to set custom chart dimensions in pixels before a WebChart() call. Pass 0 for either parameter to use the default size.
- Use fixed range for data with known bounds (humidity 0–100, UV index 0–12)
- Use auto-scale (
0, 0) for data with variable range (brightness, wind, rain) - Call from
WebPage()callback — each call emits one data series - Multiple series on one chart: first call has a title, subsequent calls use
""as title
Including HTML from files:
Use webSendFile("filename") to send the contents of a file from the device filesystem directly to the web page. This is useful for large HTML, CSS, or JavaScript that would be too big to compile into bytecode constants.
The file is read in 256-byte chunks and sent via WSContentSend. The filename can be with or without leading /.
Custom Web Handlers¶
Register custom HTTP endpoints on the Tasmota web server. When a request arrives, the WebOn() callback is invoked with the handler number accessible via webHandler().
| Function | Description |
|---|---|
webOn(int num, "url") |
Register handler 1–4 for the given URL path |
int webHandler() |
Returns the handler number (1–4) inside WebOn() callback |
int webArg("name", buf) |
Read HTTP request argument into char buffer, returns length (0 if missing) |
Use webSend(buf) to emit the response body. The response content type is text/plain by default.
Example — JSON API endpoint:
char buf[128];
void WebOn() {
int h = webHandler();
if (h == 1) {
// GET /v1/json?id=xxx
char id[32];
int len = webArg("id", id);
sprintf(buf, "{\"handler\":1,\"id\":\"%s\",\"value\":42}", id);
webSend(buf);
}
}
int main() {
webOn(1, "/v1/json");
return 0;
}
Example — Multiple endpoints:
void WebOn() {
int h = webHandler();
char buf[64];
if (h == 1) {
sprintf(buf, "{\"temp\":%.1f}", smlGet(1));
webSend(buf);
}
if (h == 2) {
webSend("OK");
}
}
int main() {
webOn(1, "/api/sensor");
webOn(2, "/api/ping");
return 0;
}
Notes:
- Up to 4 handlers can be registered (1–4)
- URLs must start with / (e.g., /v1/json, /api/data)
- webOn() is called in main() — handlers are registered at program start
- WebOn() callback runs after main() has returned (same as other callbacks)
- webArg() reads both GET query parameters and POST form fields
- Equivalent to Scripter's won(N, "/url") + >onN section
- CORS is enabled so endpoints are accessible from external apps
UDP Multicast (Scripter-compatible)¶
Share float variables between Tasmota devices via UDP multicast on 239.255.255.250:1999. Compatible with Tasmota Scripter's global variable protocol.
| Function | Description |
|---|---|
float udpRecv("name") |
Get last received value for named variable (0 if none) |
int udpReady("name") |
Returns 1 if new value received since last check |
void udpSendArray("name", float_arr, count) |
Broadcast a float array via binary multicast |
int udpRecvArray("name", float_arr, maxcount) |
Receive float array, returns actual count |
udpSendStr("name", char str[]) |
Send string via multicast (ASCII mode =>name=...) |
Protocol:
- Single float: send =>name:[4 bytes IEEE-754 float]
- Float array: send =>name:[2-byte LE count][N × 4-byte float]
- Receive: both ASCII (=>name=value) and binary (single or array)
- Multicast group: 239.255.255.250, port 1999
- Max 8 tracked variable names, 16 chars each
- Max 64 floats per array
Callback: Define void UdpCall() to be notified on each received variable.
UDP socket is auto-initialized on first global variable write, udpRecv(), or udpReady() call.
Scalar global float variables automatically broadcast via UDP when assigned (no explicit send needed).
Socket Watchdog: The multicast socket has a built-in inactivity watchdog (default: 60 seconds). If no packet is received within the timeout period, the socket is automatically closed and re-opened. This recovers from the known ESP32 issue where the UDP receive path silently stops working after a variable amount of time. Use udp(8, 0, seconds) to change the timeout (0 = disable).
Example (scalar — auto-broadcast):
global float temperature = 0.0; // declared as 'global' → auto-broadcasts on write
void EverySecond() {
temperature = 20.0 + sin(counter) * 5.0;
// No udpSend() needed — assigning a 'global' variable auto-broadcasts it
}
void UdpCall() {
float remote = udpRecv("temperature");
// process remote value...
}
Example (array):
float sensors[8];
void EverySecond() {
// Send 8 sensor values as array
udpSendArray("sensors", sensors, 8);
}
void UdpCall() {
float remote[8];
int n = udpRecvArray("sensors", remote, 8);
// n = number of floats actually received
}
General-Purpose UDP¶
Scripter-compatible udp() function for arbitrary UDP communication. Uses a separate socket from the multicast variable sharing above.
| Function | Description |
|---|---|
int udp(0, int port) |
Open a listening UDP port. Returns 1 on success |
int udp(1, char buf[]) |
Read received string into buf. Returns byte count (0 = nothing) |
void udp(2, char str[]) |
Reply to sender's IP and port |
void udp(3, char url[], char str[]) |
Send string to url using the port from udp(0) |
int udp(4, char buf[]) |
Get remote sender IP as string. Returns length |
int udp(5) |
Get remote sender port number |
int udp(6, char url[], int port, char str[]) |
Send string to arbitrary url:port |
int udp(7, char url[], int port, int arr[], int count) |
Send array as raw bytes to url:port |
int udp(8, int which, int seconds) |
Set socket inactivity timeout (which: 0=multicast, 1=general port; 0=disable) |
int udp(9, char mcast_ip[], int port) |
Join arbitrary UDP multicast group, bind to port. Returns 1 on success |
Notes:
- The first argument (mode) must be a literal integer (0-9)
- Modes 6 and 7 create a temporary socket for each send (no prior udp(0) needed)
- Mode 1 is non-blocking: returns 0 immediately if no packet is available
- Mode 7 sends the lower byte of each array element
- Mode 8 configures the socket watchdog: if no packet is received within seconds, the socket is automatically reset. Default is 60 seconds. Set to 0 to disable.
- Mode 9 joins a custom multicast group (e.g. SMA Speedwire 239.12.255.254:9522). Reuses the udp(1, buf) read path. Replaces any unicast udp(0, ...) binding on the same socket; call udp(0, port) again to switch back to unicast.
char buf[128];
char ip[20];
void main() {
udp(0, 5000); // listen on port 5000
}
void Every50ms() {
int n = udp(1, buf); // check for incoming
if (n > 0) {
udp(4, ip); // get sender IP
int port = udp(5); // get sender port
udp(2, "ACK"); // reply to sender
}
}
void sendData() {
char msg[64];
strcpy(msg, "hello");
udp(6, "192.168.1.100", 5000, msg); // send to specific IP:port
}
I2C Bus¶
Direct I2C bus access for sensor drivers (requires USE_I2C). All functions take bus as the last parameter (0 or 1).
| Function | Description |
|---|---|
int i2cExists(int addr, int bus) |
Check if device responds at address. Returns 1 if found |
int i2cRead8(int addr, int reg, int bus) |
Read single byte from register. Returns byte value (0–255) |
int i2cWrite8(int addr, int reg, int val, int bus) |
Write single byte to register. Returns 1=ok, 0=fail |
int i2cRead(int addr, int reg, char buf[], int len, int bus) |
Read len bytes into char array. Returns 1=ok |
int i2cWrite(int addr, int reg, char buf[], int len, int bus) |
Write len bytes from char array. Returns 1=ok |
int i2cRead0(int addr, char buf[], int len, int bus) |
Read len bytes without register. Returns 1=ok |
int i2cWrite0(int addr, int reg, int bus) |
Write register byte only (no data). Returns 1=ok |
int i2cSetDevice(int addr, int bus) |
Check if address is unclaimed and responsive. Returns 1=available |
i2cSetActiveFound(int addr, "type", int bus) |
Register address as claimed by your driver. Logs discovery |
int i2cReadRS(int addr, int reg, char buf[], int len, int bus) |
Read with repeated-start (SMBus). Keeps bus held between write and read phase |
I2cResetActive(int addr, int bus) |
Release a previously claimed I2C address (undo i2cSetActiveFound) |
Notes:
- bus = 0 or 1 — selects which I2C bus to use
- Address is 7-bit (0x00–0x7F), e.g. 0x48 for TMP102
- Register is 8-bit (0x00–0xFF)
- Buffer functions use char[] arrays — each element holds one byte (0–255)
- Maximum buffer length is 255 bytes
- Returns 0 if I2C is not compiled in or the operation fails
- Use i2cSetDevice + i2cSetActiveFound to properly claim I2C addresses and prevent conflicts with Tasmota's built-in drivers
Example — Read TMP102 temperature sensor on bus 0:
#define TMP102_ADDR 0x48
#define TMP102_TEMP 0x00
#define I2C_BUS 0
void EverySecond() {
if (!i2cExists(TMP102_ADDR, I2C_BUS)) return;
char buf[2];
if (i2cRead(TMP102_ADDR, TMP102_TEMP, buf, 2, I2C_BUS)) {
// TMP102: 12-bit temp in upper bits of 2 bytes
int raw = (buf[0] << 4) | (buf[1] >> 4);
if (raw > 2047) raw = raw - 4096; // sign extend
float temp = (float)raw * 0.0625;
char out[64];
sprintf(out, "TMP102: %.2f °C\n", temp);
printString(out);
}
}
Smart Meter (SML)¶
Read meter values and control meters via Tasmota's SML driver (requires USE_SML or USE_SML_M).
SML can run without Scripter — only USE_UFILESYS is needed for file-based meter descriptors.
The IDE's SML Descriptor tab manages the meter definition file (/sml_meter.def) on the device.
Reading Meter Values¶
| Function | Description |
|---|---|
float smlGet(int index) |
Get meter value. Index 0 returns count, 1..N returns values |
int smlGetStr(int index, char buf[]) |
Positive index: meter ID/OBIS string. Negative index: full-precision numeric value as string (4 decimals) — equivalent to Scripter's smls[-x] |
Notes:
- Index is 1-based: smlGet(1) returns the first meter value
- smlGet(0) returns the total number of meter variables
- Returns 0 if SML is not compiled in or index is out of range
- smlGet() values match Scripter's sml[x] syntax (single-precision float)
- smlGetStr(-i, buf) formats the underlying double SML value with 4 decimal places — use when cumulative energy meters exceed float's ~7-digit precision
Example:
void WebCall() {
char buf[64];
int n = smlGet(0); // total meters
int i = 1;
while (i <= n) {
float val = smlGet(i);
sprintf(buf, "{s}Meter %d{m}%.2f{e}", val);
webSend(buf);
i++;
}
}
Advanced Meter Control¶
These functions require USE_SML_SCRIPT_CMD to be enabled in the firmware.
| Function | Description |
|---|---|
int smlWrite(int meter, char buf[]) |
Send hex sequence to meter (e.g. wake-up or request commands) |
int smlWrite(int meter, "hex") |
Same, with string literal (no temp buffer needed) |
int smlRead(int meter, char buf[]) |
Read raw meter buffer into char array, returns bytes read |
int smlSetBaud(int meter, int baud) |
Change baud rate of a meter's serial port |
int smlSetWStr(int meter, char buf[]) |
Set async write string for next scheduled send |
int smlSetWStr(int meter, "hex") |
Same, with string literal |
int smlSetOptions(int options) |
Set SML global options bitmask |
int smlGetV(int sel) |
Get/reset data valid flags (0=get, 1=reset) |
Notes:
- meter is the 1-based meter index from the SML descriptor
- smlWrite and smlSetWStr accept either a char[] array or a string literal — the compiler auto-detects which variant to use
- smlWrite sends a hex-encoded byte sequence (e.g. "AA0100") to the meter's serial port
- smlRead copies the raw receive buffer into a char array for custom parsing
- smlSetBaud dynamically changes the meter's baud rate (useful for meters that require speed negotiation)
- smlSetWStr sets a hex string to be sent on the next scheduled meter poll cycle
- These functions replace Scripter's >F/>S section meter control commands
Example — OBIS meter wake-up sequence:
void EverySecond() {
// String literal — no temp buffer needed
smlWrite(1, "2F3F210D0A"); // "/?!\r\n" in hex
}
Example — Dynamic baud rate negotiation:
void EverySecond() {
// Read meter response
char buf[64];
int n = smlRead(1, buf);
if (n > 0 && buf[0] == 0x06) {
// ACK received, switch to high speed
smlSetBaud(1, 9600);
}
}
SML Descriptor Editor (IDE)¶
The IDE includes an SML Descriptor tab in the left pane for managing meter definitions:
- Meter database: A dropdown loads
.tasmeter definitions from the community database - Custom meter URL: The database URL is read from
/sml_meter_url.txton the device filesystem. To use a different meter repository, edit this file with a URL pointing to a directory containing asmartmeter.jsonindex file. The default URL points to the community GitHub repository. - RX/TX pin selection: Dropdowns populated from the device's free GPIOs (via
freegpioAPI) - Pin placeholders:
%0rxpin%and%0txpin%in descriptors are replaced with selected pins on save - Save to Device: Extracts only the
>Msection and saves it as/sml_meter.def - Load from Device: Reads the current
/sml_meter.deffrom the device
Callback Merge¶
Many .tas meter files require periodic code (Scripter's >S and >F sections) for meter communication, wake-up sequences, or baud rate negotiation. In TinyC, you write these as callback functions directly in the SML editor:
void EverySecond() {
smlWrite(1, "2F3F210D0A");
}
>M 1
+1,3,s,16,9600,SML,1
1,1-0:1.8.0*255(@1,Energy In,kWh,E_in,3
#
How it works:
1. Write TinyC callback functions (EverySecond(), Every100ms(), etc.) anywhere in the SML editor — before or after the >M section
2. On Save, only the >M section goes to /sml_meter.def on the device
3. On Compile, the IDE automatically merges SML callbacks into the main program:
- If the main editor already has the same callback — the SML code is appended to the existing function body
- If the main editor doesn't have it — a new callback function is created
4. The merged source is compiled as one program — SML code and main code share the same globals and functions
SPI Bus¶
Direct SPI bus access for sensors and displays. Supports both hardware SPI (using Tasmota-configured pins) and software bitbang on arbitrary GPIO pins.
| Function | Description |
|---|---|
int spiInit(int sclk, int mosi, int miso, int speed_mhz) |
Initialize SPI bus. Returns 1=ok |
spiSetCS(int index, int pin) |
Set chip select pin for slot index (1–4) |
int spiTransfer(int cs, char buf[], int len, int mode) |
Transfer bytes. Returns bytes transferred |
spiInit pin modes:
- sclk = -1 — Use Tasmota's primary hardware SPI bus (GPIO configured in Tasmota)
- sclk = -2 — Use HSPI secondary hardware SPI bus (ESP32 only)
- sclk >= 0 — Bitbang mode using GPIO pins (sclk, mosi, miso)
- Set mosi or miso to -1 if not needed (e.g. read-only or write-only device)
- speed_mhz sets clock frequency for hardware SPI (ignored for bitbang)
spiTransfer modes:
| Mode | Description |
|------|-------------|
| 1 | 8-bit per element — each buf[] element = 1 byte transferred |
| 2 | 16-bit per element — each buf[] element = 2 bytes (MSB first) |
| 3 | 24-bit per element — each buf[] element = 3 bytes (MSB first) |
| 4 | 8-bit with per-byte CS toggle — CS goes low/high for each byte |
Notes:
- cs parameter is 1-based CS slot index (matching spiSetCS). Use 0 for no automatic CS management
- Transfer is full-duplex: buf[] is written (MOSI) and read values (MISO) replace each element
- Maximum practical transfer length is limited by your char array size
- SPI resources are automatically cleaned up when the VM stops
- Hardware SPI requires SPI pins configured in Tasmota (Template or Module settings)
Example — Read MAX31855 thermocouple (SPI, 32-bit read):
#define CS_PIN 5
int main() {
spiInit(-1, -1, -1, 4); // HW SPI at 4 MHz
spiSetCS(1, CS_PIN); // CS slot 1 = pin 5
char buf[4];
buf[0] = 0; buf[1] = 0; buf[2] = 0; buf[3] = 0;
spiTransfer(1, buf, 4, 1); // read 4 bytes
// MAX31855: bits 31..18 = 14-bit thermocouple temp
int raw = ((buf[0] << 8) | buf[1]) >> 2;
if (raw & 0x2000) raw = raw - 16384; // sign extend
float temp = (float)raw * 0.25;
char out[64];
sprintf(out, "Thermocouple: %.2f °C\n", temp);
printString(out);
return 0;
}
Display Drawing¶
Requires a Tasmota build with USE_DISPLAY enabled and a configured display driver. All drawing functions operate on the Tasmota display renderer directly — much more efficient than building DisplayText command strings.
Setup & Control¶
| Function | Description |
|---|---|
dspClear() |
Clear display, reset position to (0,0) |
dspPos(x, y) |
Set current draw position (pixels) |
dspFont(f) |
Set font (0-7), resets text size to 1 for non-GFX fonts |
dspSize(s) |
Set text size multiplier |
dspColor(fg, bg) |
Set foreground and background color (16-bit RGB565) |
dspPad(n) |
Set text padding for dspDraw(): positive = left-aligned padded to n chars, negative = right-aligned padded to n chars, 0 = off |
dspDim(val) |
Set display brightness (0-15) |
dspOnOff(on) |
Turn display on (1) or off (0) |
dspUpdate() |
Force display update (required for e-paper displays) |
dspWidth() |
Returns display width in pixels |
dspHeight() |
Returns display height in pixels |
Drawing Primitives¶
All primitives use the current position set by dspPos() and the current foreground color set by dspColor().
| Function | Description |
|---|---|
dspDraw(buf) |
Draw text string at current position |
dspPixel(x, y) |
Draw single pixel at (x,y) |
dspLine(x1, y1) |
Draw line from current pos to (x1,y1), updates pos |
dspHLine(w) |
Horizontal line from current pos, width w, updates pos |
dspVLine(h) |
Vertical line from current pos, height h, updates pos |
dspRect(w, h) |
Draw rectangle outline at current pos |
dspFillRect(w, h) |
Draw filled rectangle at current pos |
dspCircle(r) |
Draw circle outline at current pos with radius r |
dspFillCircle(r) |
Draw filled circle at current pos |
dspRoundRect(w, h, r) |
Rounded rectangle at current pos with corner radius r |
dspFillRoundRect(w, h, r) |
Filled rounded rectangle |
dspTriangle(x1, y1, x2, y2) |
Triangle from current pos to (x1,y1) and (x2,y2) |
dspFillTriangle(x1, y1, x2, y2) |
Filled triangle |
Image & Raw Commands¶
| Function | Description |
|---|---|
dspPicture("file.jpg", scale) |
Draw image file from filesystem at current pos (scale: 0=original) |
int dspLoadImage("file.jpg") |
Load JPG into PSRAM as RGB565 pixel store, returns slot 0-3 (-1 on error). Stays in memory until VM stops. ESP32+JPEG_PICTS only |
int imgCreate(w, h) |
Allocate a blank RGB565 canvas (w×h pixels, 2 bytes/pixel) in PSRAM and return its image slot id (0-3, -1 on OOM / no free slot). The slot behaves exactly like a JPG-loaded slot for dspPushImageRect/dspImageWidth/dspImageHeight/dspImgText[Burn], but additionally supports imgBeginDraw(). Max 1024×1024. Freed automatically on TinyCStop |
imgBeginDraw(slot) |
Redirect all dsp* drawing primitives (dspLine, dspFillCircle, dspText, dspRect, dspPixel, dspTriangle, …) into the canvas slot's pixel buffer instead of the physical display. Must be paired with imgEndDraw(). Nested begin-calls are ignored (Phase 1 = single redirect at a time). Slot must have been created with imgCreate (JPG-loaded slots are rejected) |
imgEndDraw() |
Restore the physical display as the target for dsp* primitives. No-op if no redirect is active |
imgClear(slot, color) |
Fast fill of the whole canvas with an RGB565 color. Equivalent to imgBeginDraw(slot); dspColor(color,color); dspFillRect(...); imgEndDraw(); but much faster (memset-based). Marks the whole canvas dirty |
imgBlit(dst, src, sx, sy, dx, dy, w, h) |
Copy a rectangle from one canvas to another (or within the same canvas — memmove-safe). Source rect (sx,sy,w,h) → dest rect at (dx,dy). Both sides are clipped, so out-of-bounds args are harmless. The destination's dirty region is unioned with the touched rect automatically. Typical use: keep a clean reference canvas alongside a working canvas, then imgBlit(work, clean, x, y, x, y, w, h) to restore a small area before redrawing |
imgInvalidate(slot, x, y, w, h) |
Manually union a rect into the slot's dirty region — useful after direct buffer mutations, or to force a flush of an area you know you changed but markDirty didn't catch |
imgFlush(slot, panel_x, panel_y) |
Blit only the dirty region of a canvas to the panel at (panel_x+dx, panel_y+dy) and then clear the dirty rect. Draw primitives into a canvas and call imgFlush at the end of a frame — no manual bounding-box math needed. Must NOT be called while an imgBeginDraw redirect is active. No-op if dirty is empty |
dspPushImageRect(slot, sx, sy, dx, dy, w, h) |
Push a sub-rectangle from a loaded image (JPG or canvas) to screen. Reads from image at (sx,sy), writes to screen at (dx,dy), size w×h. Use for dirty-rect background restore (e.g., analog clock hands over a watchface, or needle over a procedurally-drawn gauge face). Do not call while a canvas redirect is active — it would no-op, since setAddrWindow/pushColors are stubbed on the canvas target. Unlike imgFlush, does NOT consult or clear the canvas's dirty region |
int dspImageWidth(slot) |
Get width of loaded image in slot (0 if invalid) |
int dspImageHeight(slot) |
Get height of loaded image in slot (0 if invalid) |
int dspTextWidth(len) |
Get pixel width for len characters in current font and text size. For transparent text on image backgrounds: measure text, draw text, later restore background with dspPushImageRect using the measured bounds |
int dspTextHeight() |
Get pixel height for current font and text size |
dspImgText(slot, x, y, color, fieldWidth, align, text) |
Composite text onto an image sub-rect in RAM and push the result in a single SPI transaction (flicker-free). The image buffer provides the background pixels; only foreground font pixels are overwritten. slot: image slot from dspLoadImage(). x, y: pixel position on the image (and screen). color: RGB565 text color. fieldWidth: total field width in characters — if larger than text length, remaining area shows image background; use 0 for auto (fits text exactly). align: 0=left, 1=right, 2=center (alignment within the field). text: the string to render. Works with EPD fonts 1-4 (set via dspText("[f1]")..dspText("[f4]")) at any text size. Example: dspText("[f2s1]"); dspImgText(img, 10, 10, 0, 28, 0, buf); |
int dspLoadImageFromCam(cam_slot) |
Decode the JPEG already held in a PSRAM cam slot (1-4, captured via camControl(10, ...)) into a free RGB565 image slot (0-3). Returns the new image slot, or -1 on failure. The source cam slot is untouched. ESP32+JPEG_PICTS+camera only |
dspImgTextBurn(slot, x, y, color, fieldWidth, align, text) |
Write glyph pixels directly into an image buffer — unlike dspImgText this does NOT touch the display. Use on headless cam boards (no TFT attached) to burn timestamps/labels into a frame before re-encoding. Falls back to built-in Font12 at size 1 if no renderer is active; if a display IS attached, honors its current font + size (dspText("[f2s1]") etc.). Parameters identical to dspImgText |
int dspImageToCam(img_slot, cam_slot, quality) |
Re-encode an RGB565 image slot back into a cam slot as JPEG (via esp32-camera fmt2jpg). quality range 1..63 (esp_camera convention, lower=better; 12 ≈ JPEG Q=85). Returns encoded byte size, or -1 on failure. Result is ready for camControl(11, cam_slot, fh) to save-to-file, email attach, or any other cam-slot consumer |
dspText(buf) |
Execute raw DisplayText command string (e.g., "[z][x50][y20]Hello") |
Predefined Color Constants (RGB565)¶
The following color constants are predefined — no #define needed:
| Constant | Value | Constant | Value |
|---|---|---|---|
BLACK |
0 | WHITE |
65535 |
RED |
63488 | GREEN |
2016 |
BLUE |
31 | YELLOW |
65504 |
CYAN |
2047 | MAGENTA |
63519 |
ORANGE |
64800 | PURPLE |
30735 |
GREY |
33808 | DARKGREY |
21130 |
LIGHTGREY |
50712 | DARKGREEN |
992 |
NAVY |
16 | MAROON |
32768 |
OLIVE |
33792 |
User #define overrides take precedence over predefined colors.
Example¶
int counter;
char buf[32];
void EverySecond() {
counter++;
dspClear();
dspColor(WHITE, BLACK); // white on black
// Title
dspFont(2);
dspSize(2);
dspPos(10, 10);
dspDraw("TinyC Display");
// Counter
dspFont(1);
dspSize(1);
sprintf(buf, "Count: %d", counter);
dspPos(10, 60);
dspDraw(buf);
// Draw a red box around the counter
dspColor(RED, BLACK);
dspPos(5, 55);
dspRect(150, 25);
// Draw a blue filled circle
dspColor(BLUE, BLACK);
dspPos(200, 80);
dspFillCircle(20);
dspUpdate(); // needed for e-paper
}
int main() {
counter = 0;
dspClear();
return 0;
}
Touch Buttons & Sliders¶
Create GFX touch buttons and sliders on the display. Colors are RGB565 values (use predefined constants like WHITE, BLUE, etc.).
Button Creation¶
| Function | Description |
|---|---|
dspButton(num, x, y, w, h, oc, fc, tc, ts, "text") |
Create power button (controls relay num) |
dspTButton(num, x, y, w, h, oc, fc, tc, ts, "text") |
Create virtual toggle button (MQTT TBT) |
dspPButton(num, x, y, w, h, oc, fc, tc, ts, "text") |
Create virtual push button (MQTT PBT) |
dspSlider(num, x, y, w, h, nelem, bg, fc, bc) |
Create slider |
Parameters: num = button index (0-15), x,y = position, w,h = size, oc = outline color, fc = fill color, tc = text color, ts = text size, nelem = slider segments, bg = background color, bc = bar color.
State Control & Reading¶
| Function | Description |
|---|---|
dspButtonState(num, val) |
Set button state (0/1) or slider value (0-100) |
int touchButton(num) |
Read button state: 0/1 for buttons, -1 if undefined |
dspButtonDel(num) |
Delete button/slider num, or all if num is -1 |
Touch Callback¶
The TouchButton callback is called on touch events with the button index and value:
void TouchButton(int btn, int val) {
if (btn == 0) {
// Toggle button pressed, val = 0 or 1
char buf[16];
sprintf(buf, "%d", val);
tasmCmd("Power1", buf);
}
if (btn == 1) {
// Slider moved, val = 0-100
char buf[16];
sprintf(buf, "%d", val);
tasmCmd("Dimmer", buf);
}
}
int main() {
dspTButton(0, 10, 10, 100, 50, WHITE, BLUE, WHITE, 2, "Light");
dspSlider(1, 10, 80, 200, 40, 10, DARKGREY, WHITE, CYAN);
return 0;
}
TinyUI — Retained-Mode Widget Layer¶
A thin, retained-mode UI layer on top of the primitive dsp* calls. It adds:
- Screens — keep up to 256 logical screens; switching clears the canvas, removes interactive widgets, and re-draws passive widgets tagged with the new screen.
- Theme — a single global colour palette + padding applied to all widgets.
- Passive widgets (
uiLabel,uiProgress,uiGauge) — stored in a separate pool (tc_ui_widgets[TC_UI_MAX_WIDGETS], default 16 entries). They surviveuiScreen()switches and redraw automatically. - Interactive widgets (
uiCheckbox,uiIcon) — backed by the existing VButton pool (MAX_TOUCH_BUTTONSentries). They dispatch through the normalTouchButton(num, state)callback. Different index space from passive widgets.
TinyUI is "really tiny": ~400 LOC of C, zero extra RAM when unused, no extra dependencies, and it reuses the existing display renderer. Compare to LVGL (~150–500 KB flash, 10–30 KB RAM).
API¶
| Function | Description |
|---|---|
uiScreen(int id) |
Switch to screen id (0..255). Clears canvas with theme.bg, deletes all VButtons, redraws passive widgets tagged with the new screen. Call your build_screenN() afterwards to re-create interactive widgets. |
uiTheme(bg, accent, text, border) |
Set global palette (RGB565). Used by widgets created afterwards. |
uiClearScreen() |
Fill canvas with theme.bg. |
uiLabel(num, x, y, w, h, "text", align) |
Passive text label in widget pool slot num (0..15). align: -1=right, 0=centre, 1=left. |
uiLabelSet(num, "text") or uiLabelSet(num, buf) |
Update a label's text and redraw. Accepts a const string literal or a char[] buffer. |
uiProgress(num, x, y, w, h, value, max) |
Horizontal progress bar. Range 0..max. |
uiProgressSet(num, value) |
Update bar value + redraw. |
uiGauge(num, x, y, r, value, vmin, vmax) |
240° arc gauge centred at x,y, radius r. Calling again with the same num re-renders (needle sweeps). |
uiCheckbox(num, x, y, w, h, "label") |
Interactive latching toggle using VButton slot num, with caller-sized hit area (w × h, minimum 8×8). One TouchButton(num, state) per tap, state = new latched value (0/1). |
uiButton(num, x, y, w, h, "label") |
Momentary pushbutton in VButton slot num (same hit-area rules as uiCheckbox). Fires TouchButton(num, 1) on press and TouchButton(num, 0) on release — useful for trigger actions (pulse, bell, next). |
uiIcon(num, x, y, img_slot) |
(reserved) image-backed icon — wiring to the image slot subsystem is pending. |
Passive widgets (Label/Progress/Gauge) use one index space (0..15). Checkboxes / pushbuttons / icons share the VButton index space (0..MAX_TOUCH_BUTTONS-1). The two spaces do not collide with each other.
Example¶
int current = 1;
float power = 0;
void build_screen1() {
uiLabel(0, 0, 0, 320, 30, "Dashboard", 0);
uiLabel(1, 10, 50, 150, 20, "Power: 0 W", 1);
uiProgress(3, 10, 80, 300, 18, 0, 1000);
}
void main() {
uiTheme(0x0000, 0x07FF, 0xFFFF, 0x39E7); // bg, accent, text, border
uiScreen(1);
build_screen1();
}
void EverySecond() {
power = power + 50; if (power > 1000) power = 0;
char buf[32];
sprintfFloat(buf, "Power: %.0f W", power);
uiLabelSet(1, buf);
uiProgressSet(3, power);
}
See examples/tinyui_demo.tc for a 3-screen demo with live values, an arc gauge, and interactive checkboxes.
Compile-time limits¶
| Constant | Default | Purpose |
|---|---|---|
TC_UI_MAX_WIDGETS |
16 | Passive widget pool size (tc_ui_widgets[]) |
MAX_TOUCH_BUTTONS |
16 | Interactive VButton pool (shared with dspButton/dspTButton/…) |
Audio¶
| Function | Description |
|---|---|
audioVol(int vol) |
Set audio volume (0-100) |
audioPlay("file.mp3") |
Play MP3 file from filesystem |
audioSay("hello") |
Text-to-speech output |
Requires I2S audio driver configured on the device.
audioVol(50); // set volume to 50%
audioPlay("/alarm.mp3"); // play MP3 file
audioSay("sensor alert"); // speak text
Persistent Variables¶
| Function | Description |
|---|---|
saveVars() |
Save all persist globals to the program's .pvs file |
Persist variables are automatically loaded on program start and saved on TinyCStop. Use saveVars() to save at critical points (e.g., after midnight counter updates).
Watch Variables (Change Detection)¶
| Function | Description |
|---|---|
changed(var) |
Returns 1 if watch variable differs from its shadow value |
delta(var) |
Returns current - shadow (int or float depending on variable type) |
written(var) |
Returns 1 if variable was assigned since last snapshot() |
snapshot(var) |
Update shadow to current value and clear written flag |
Watch variables are compiler intrinsics — they generate inline comparison code with zero runtime overhead (no syscall).
Deep Sleep (ESP32)¶
| Function | Description |
|---|---|
deepSleep(int seconds) |
Enter deep sleep with timer wakeup after seconds |
deepSleepGpio(int seconds, int pin, int level) |
Deep sleep with timer + GPIO wakeup (0=low, 1=high) |
int wakeupCause() |
Returns ESP32 wakeup cause (0=reset, 2=EXT0, 3=EXT1, 4=timer, 5=touchpad, ...) |
Persist variables and settings are saved automatically before entering deep sleep.
// Wake every 5 minutes to read sensor
int cause = wakeupCause();
if (cause == 4) {
// woke from timer — read sensor, send data
}
deepSleep(300); // sleep 300 seconds
// Sleep until GPIO12 goes HIGH (or 1 hour max)
deepSleepGpio(3600, 12, 1);
Hardware Registers (ESP32)¶
Direct read/write access to ESP32 memory-mapped peripheral registers. Only addresses in the peripheral range are allowed (0x3FF00000–0x3FFFFFFF or 0x60000000–0x600FFFFF).
| Function | Description |
|---|---|
int peekReg(int addr) |
Read 32-bit value from peripheral register |
pokeReg(int addr, int val) |
Write 32-bit value to peripheral register |
Warning: Incorrect register writes can crash or damage the device. Only use if you know what you're doing.
Email (ESP32 — requires USE_SENDMAIL)¶
| Function | Description |
|---|---|
mailBody(body) |
Set email body text (HTML). body is a char[] array |
mailAttach("/path") |
Add file attachment from filesystem (string literal, up to 8) |
int mailSend(params) |
Send email. params is char[] with [server:port:user:passwd:from:to:subject]. Returns 0=ok |
For simple emails without attachments, put body text after the ] in params:
char cmd[200];
strcpy(cmd, "[smtp.gmail.com:465:user:pass:from@x.com:to@y.com:Alert] Sensor triggered!");
int result = mailSend(cmd);
For emails with file attachments, use mailBody() and mailAttach() before mailSend():
// Build body
char body[200];
sprintf(body, "<h1>Daily Report</h1><p>Temperature: %d C</p>", "%.1f");
// Register body and attachments
mailBody(body);
mailAttach("/data.csv");
mailAttach("/log.txt");
// Send — params only need [server:port:user:passwd:from:to:subject]
char params[200];
strcpy(params, "[*:*:*:*:*:to@example.com:Daily Report]");
int result = mailSend(params);
// result: 0=ok, 1=parse error, 4=memory error
Use * for server/port/user/password/from fields to use #define defaults from user_config_override.h.
Tesla Powerwall (ESP32 — requires TESLA_POWERWALL)¶
Access Tesla Powerwall local API via HTTPS. Uses the email library's SSL implementation (standard Arduino SSL does not work with Powerwall).
Requires: #define TESLA_POWERWALL in user_config_override.h and the ESP-Mail-Client library.
| Function | Description |
|---|---|
int pwlRequest(url) |
Config command or API request. Returns 0=ok, -1=fail |
pwlBind(&var, path) |
Register a global float variable for auto-fill. Path uses # separator (max 24 bindings) |
float pwlGet(path) |
Extract float from last response. Supports [N] suffix for nth occurrence |
int pwlStr(path, buf) |
Extract string from last response into char[] buffer. Returns length |
Recommended approach — pwlBind (parse once, fill all):
Register global variables with JSON paths in Setup(). When pwlRequest() receives a response, the JSON is parsed once and all matching bound variables are filled directly. No string replacements, no repeated parsing.
float sip, sop, bip, hip, pwl, rper;
void Setup() {
pwlRequest("@D192.168.188.60,email@example.com,mypassword");
pwlRequest("@C0x000004714B006CCD,0x000004714B007969");
// Register bindings — use original JSON key names
pwlBind(&sip, "site#instant_power");
pwlBind(&sop, "solar#instant_power");
pwlBind(&bip, "battery#instant_power");
pwlBind(&hip, "load#instant_power");
pwlBind(&pwl, "percentage");
pwlBind(&rper, "backup_reserve_percent");
}
void Loop() {
// All matching bindings filled automatically:
pwlRequest("/api/meters/aggregates");
// sip, sop, bip, hip are now set
pwlRequest("/api/system_status/soe");
// pwl is now set
pwlRequest("/api/operation");
// rper is now set
}
Configuration prefixes:
| Prefix | Description |
|--------|-------------|
| @Dip,email,password | Configure IP and credentials |
| @Ccts1,cts2 | Configure CTS serial numbers (masked in responses) |
| @N | Clear auth cookie (force re-authentication) |
Common API endpoints:
| Endpoint | Data |
|----------|------|
| /api/meters/aggregates | Site, battery, load, solar power (W) |
| /api/system_status/soe | State of energy / battery percentage |
| /api/system_status | System status info |
| /api/operation | Operation mode, reserve percentage |
| /api/meters/readings | Detailed meter readings per CTS |
Nth-occurrence extraction: pwlGet("key[N]") extracts the Nth occurrence of a repeated key from the JSON response. Useful for /api/meters/readings which has multiple CTS objects with the same key names:
// Per-phase grid readings — CTS2 grid phases are occurrences 6,7,8 of "p_W"
phs1 = pwlGet("p_W[6]");
phs2 = pwlGet("p_W[7]");
phs3 = pwlGet("p_W[8]");
Ad-hoc access: pwlGet() and pwlStr() are available for one-off value extraction from the last response, but pwlBind() is preferred for repeated polling since it avoids re-parsing.
Addressable LED Strip (WS2812 — requires USE_WS2812)¶
Control WS2812 / NeoPixel addressable LED strips directly from TinyC.
Requires: #define USE_WS2812 in user_config_override.h.
| Function | Description |
|---|---|
setPixels(array, len, offset) |
Set len pixels from array, starting at strip position offset & 0x7FF. Updates strip immediately. |
Color format: Each array element is 0xRRGGBB (24-bit RGB packed into an int).
RGBW mode: Set bit 12 of offset (offset | 0x1000) for RGBW mode. In RGBW mode, two consecutive array elements encode one pixel (high word = 0x00RG, low word = 0xBW00).
Example — Rainbow effect:
int leds[60];
void setup() {
for (int i = 0; i < 60; i++) {
int hue = (i * 256) / 60;
leds[i] = hueToRGB(hue);
}
setPixels(leds, 60, 0);
}
int hueToRGB(int h) {
int r, g, b;
int region = h / 43;
int remainder = (h - region * 43) * 6;
switch (region) {
case 0: r = 255; g = remainder; b = 0; break;
case 1: r = 255 - remainder; g = 255; b = 0; break;
case 2: r = 0; g = 255; b = remainder; break;
case 3: r = 0; g = 255 - remainder; b = 255; break;
case 4: r = remainder; g = 0; b = 255; break;
default: r = 255; g = 0; b = 255 - remainder; break;
}
return (r << 16) | (g << 8) | b;
}
ESP Camera (ESP32)¶
Camera support for ESP32 boards with OV2640/OV3660/OV5640 sensors. Two modes available:
- Tasmota webcam driver (sel 0-7): Uses the standard
USE_WEBCAMdriver. DefineUSE_WEBCAMinuser_config_override.h. - TinyC integrated camera (sel 8-18): Direct esp_camera driver with board-specific pins, MJPEG streaming on port 81, and PSRAM slot management. Define
USE_TINYC_CAMERA(via-DTINYC_CAMERAbuild flag). NoUSE_WEBCAMdependency.
Both modes support mailAttachPic() for email picture attachments (up to 4 pictures per email).
Camera Init with Custom Pins (TinyC integrated mode)¶
// Pin array order: pwdn, reset, xclk, sda, scl, d7..d0, vsync, href, pclk
int campins[] = {-1, -1, 15, 4, 5, 16, 17, 18, 12, 10, 8, 9, 11, 6, 7, 13};
int ok = cameraInit(campins, PIXFORMAT_JPEG, FRAMESIZE_VGA, 12, 0, 0, -1);
| Function | Description |
|---|---|
cameraInit(pins[], format, framesize, quality, fb_count, grab_mode, xclk_freq) |
Init camera with pin array. Returns 0=ok, non-zero=error. fb_count=0 auto, grab_mode=0 auto, xclk_freq=-1 default 20MHz. |
Camera Control (camControl)¶
All camera operations use camControl(sel, p1, p2):
Tasmota webcam driver (sel 0-7, requires USE_WEBCAM):
| sel | Function | Description |
|---|---|---|
| 0 | camControl(0, resolution, 0) |
Init via Tasmota driver (WcSetup) |
| 1 | camControl(1, bufnum, 0) |
Capture to Tasmota pic buffer (1-4) |
| 2 | camControl(2, option, value) |
Set options (WcSetOptions) |
| 3 | camControl(3, 0, 0) |
Get width |
| 4 | camControl(4, 0, 0) |
Get height |
| 5 | camControl(5, on_off, 0) |
Start/stop Tasmota stream server |
| 6 | camControl(6, param, 0) |
Motion detection (-1=read motion, -2=read brightness, ms=interval) |
TinyC integrated camera (sel 7-18, requires USE_WEBCAM or USE_TINYC_CAMERA):
| sel | Function | Description |
|---|---|---|
| 7 | camControl(7, bufnum, fileHandle) |
Save picture buffer to file, returns bytes written |
| 8 | camControl(8, 0, 0) |
Get sensor PID (e.g. 0x2642 = OV2640, 0x3660 = OV3660) |
| 9 | camControl(9, param, value) |
Set sensor parameter (see table below) |
| 10 | camControl(10, slot, 0) |
Capture to PSRAM slot (1-4), returns JPEG size in bytes |
| 11 | camControl(11, slot, fileHandle) |
Save PSRAM slot to file, returns bytes written |
| 12 | camControl(12, slot, 0) |
Free PSRAM slot (0 = free all slots) |
| 13 | camControl(13, 0, 0) |
Deinit camera + free all slots + stop stream |
| 14 | camControl(14, slot, 0) |
Get slot size in bytes (0 if empty) |
| 15 | camControl(15, on_off, 0) |
Start/stop MJPEG stream server on port 81 |
| 16 | camControl(16, interval_ms, threshold) |
Enable motion detection (0=disable) |
| 17 | camControl(17, sel, 0) |
Get motion value: 0=trigger, 1=brightness, 2=triggered, 3=interval |
| 18 | camControl(18, 0, 0) |
Free motion reference buffer |
| 19 | camControl(19, addr, mask) |
Read raw sensor register at addr, masked by mask |
| 20 | camControl(20, addr, val) |
Write raw value val to sensor register at addr |
Capture (sel 10) copies the JPEG from the camera framebuffer to a PSRAM slot and immediately returns the camera framebuffer, allowing fast consecutive captures. Up to 4 slots can hold pictures simultaneously.
Important: Camera capture (camControl(10, ...)) must run in TaskLoop() (VM task thread). Calling from EverySecond() (main thread) will freeze the device.
Stream server (sel 15): Starts an MJPEG server on port 81 with /stream, /cam.mjpeg, and /cam.jpg endpoints. Automatically deferred if WiFi is not ready yet (safe for autoexec). The stream is embedded on the Tasmota main page via FUNC_WEB_ADD_MAIN_BUTTON.
Sensor Parameters (sel=9)¶
| param | Setting | Range |
|---|---|---|
| 0 | vflip | 0/1 |
| 1 | brightness | -2..2 |
| 2 | saturation | -2..2 |
| 3 | hmirror | 0/1 |
| 4 | contrast | -2..2 |
| 5 | framesize | FRAMESIZE_* |
| 6 | quality | 10..63 |
| 7 | sharpness | -2..2 |
Email Picture Attachments¶
Pictures captured to PSRAM slots are available for email via mailAttachPic(). Up to 4 pictures can be attached per email:
// Capture 2 pictures to slots 1 and 2
camControl(10, 1, 0);
camControl(10, 2, 0);
// Send email with both pictures attached
mailBody("Motion alarm");
mailAttachPic(1);
mailAttachPic(2);
mailSend("[*:*:*:*:*:user@example.com:Alarm]");
Capture and Save Example¶
// Capture to PSRAM slot 1
int size = camControl(10, 1, 0);
// Save slot 1 to file
int fh = fileOpen(path, 1); // open for write
int written = camControl(11, 1, fh);
fileClose(fh);
// Start MJPEG stream on port 81
camControl(15, 1, 0);
Timestamp / Text Overlay on JPEG Frames (cam ↔ image bridge)¶
Three helper syscalls bridge cam slots (JPEG in PSRAM, written by camControl(10)) and image slots (RGB565 in PSRAM, used by the display subsystem). Together they form a capture → decode → overlay text → re-encode → save/stream pipeline on any cam board, with or without a physically wired display.
| Function | Description |
|---|---|
int dspLoadImageFromCam(cam_slot) |
Decode JPEG from cam slot into a free RGB565 image slot. Returns image slot, -1 on failure. Source cam slot untouched |
dspImgTextBurn(slot, x, y, color, fieldw, align, text) |
Write glyph pixels directly into the image buffer (no display push). Same parameters as dspImgText. Works headless |
int dspImageToCam(img_slot, cam_slot, quality) |
Re-encode image slot back into cam slot as JPEG. Returns bytes, -1 on failure. quality 1..63 (12 ≈ Q=85) |
Build prerequisites: USE_WEBCAM or USE_TINYC_CAMERA, plus USE_DISPLAY (for the font tables — the TFT does not need to be wired), on ESP32 with JPEG_PICTS / PSRAM.
Example — burn timestamp into a VGA capture, save to filesystem:
#define CAM_IN 1 // cam slot holding the raw capture
#define CAM_OUT 2 // cam slot that will hold the stamped JPEG
int counter;
char path[40];
char line[48];
void main() {
camControl(0, 8); // init camera, FRAMESIZE_VGA (8 = VGA)
}
void TaskLoop() {
if (camControl(10, CAM_IN, 0) <= 0) { return; } // capture JPEG
int img = dspLoadImageFromCam(CAM_IN); // → RGB565
if (img < 0) { return; }
sprintf(line, "%04d-%02d-%02d %02d:%02d:%02d",
tasm_year, tasm_month, tasm_day,
tasm_hour, tasm_minute, tasm_second);
dspImgTextBurn(img, 10, 10, YELLOW, 0, 0, line); // overlay
int jlen = dspImageToCam(img, CAM_OUT, 12); // re-encode
if (jlen <= 0) { return; }
counter = counter + 1;
sprintf(path, "/snap_%04d.jpg", counter);
int fh = fileOpen(path, 1);
if (fh >= 0) {
camControl(11, CAM_OUT, fh); // save to FS
fileClose(fh);
}
delay(60000); // one snapshot per minute
}
Notes:
- Must run capture + decode + re-encode in TaskLoop() (VM task thread). Calling from EverySecond() freezes the device (same rule as plain camControl(10)).
- Font selection follows the display stack: call dspText("[f2s1]") before dspImgTextBurn to pick a larger font. On a headless board with no display driver loaded, falls back to Font12 at size 1.
- The result in CAM_OUT behaves exactly like a fresh capture — you can camControl(11) it to a file, mailAttachPic() it, or route it through the stream server.
See snap_with_timestamp.tc for the full pipeline above.
Complete Camera Script¶
See webcam_tinyc.tc for a full security camera example with MJPEG streaming, motion detection, PIR alarm, email alerts, timelapse, and auto-cleanup. See webcam.tc for the equivalent using the Tasmota webcam driver.
HomeKit (ESP32 — requires USE_HOMEKIT)¶
Apple HomeKit integration — expose devices directly from TinyC as HomeKit accessories. Sensors, lights, switches, and outlets become controllable via Apple Home. All HomeKit-bound variables use native float values — no x10 scaling needed.
Requires: #define USE_HOMEKIT in user_config_override.h.
Predefined HomeKit Constants¶
| Constant | Value | HAP Category | Variables |
|---|---|---|---|
HK_TEMPERATURE |
1 | Sensor (Temperature) | 1: temperature in °C |
HK_HUMIDITY |
2 | Sensor (Humidity) | 1: humidity in % |
HK_LIGHT_SENSOR |
3 | Sensor (Ambient Light) | 1: lux value |
HK_BATTERY |
4 | Sensor (Battery) | 3: level, low-battery flag, charging state |
HK_CONTACT |
5 | Sensor (Contact) | 1: open/closed |
HK_SWITCH |
6 | Switch | 1: on/off |
HK_OUTLET |
7 | Outlet | 1: on/off |
HK_LIGHT |
8 | Light (Color) | 4: power, hue, saturation, brightness |
HomeKit Functions¶
| Function | Description |
|---|---|
hkSetCode(code) |
Set pairing code (format: "XXX-XX-XXX") |
hkAdd(name, type) |
Add device — name and type (e.g. HK_TEMPERATURE) |
hkVar(variable) |
Bind a float variable to the current device |
int hkReady(variable) |
Returns 1 if HomeKit changed this variable since last check (auto-clears) |
int hkStart() |
Finalize descriptor and start HomeKit. Returns 0=ok |
int hkInit(char descriptor[]) |
Start HomeKit with a raw descriptor char array (advanced — bypasses builder pattern) |
hkReset() |
Erase all pairing data (factory reset). Re-pair after reboot |
hkStop() |
Stop HomeKit server |
hkReady() — Change Polling¶
hkReady(var) works like udpReady() — it returns 1 if Apple Home has changed this variable since the last call, and automatically clears the flag. The firmware writes the value directly into the global variable, so no manual assignment is needed. Use hkReady() to forward changed values via UDP:
void EverySecond() {
// global variables auto-broadcast on assignment — no explicit udpSend needed
}
HomeKitWrite Callback (Optional)¶
Called when Apple Home changes a value. The value is already written to the global variable before this callback runs — use it only for local side effects like relay forwarding:
void HomeKitWrite(int dev, int var, float val) {
// dev = device index (order of hkAdd calls, starting at 0)
// var = variable index (order of hkVar calls per device, starting at 0)
// val = new float value from Apple Home (already stored in global)
// Only needed for side effects like tasm_power = 1
}
Builder Pattern (hkAdd + hkVar)¶
Devices are defined step by step. hkAdd() starts a device, hkVar() binds float variables to it. Use multiple hkVar() calls for devices with multiple characteristics (e.g. color light):
// Color light — 4 variables: power, hue, saturation, brightness
float pwr, hue, sat, bri;
hkSetCode("111-22-333");
hkAdd("Lamp", HK_LIGHT);
hkVar(pwr); hkVar(hue); hkVar(sat); hkVar(bri);
// Simple sensor — 1 variable
float temp;
hkAdd("Temperature", HK_TEMPERATURE);
hkVar(temp);
hkStart();
Full Example — Office with Light + Sensors¶
// HomeKit-bound variables (native float values)
float mh_pwr, mh_hue, mh_sat, mh_bri; // color light
float elamp; // corner light on/off
float btemp; // temperature (e.g. 22.5)
float bhumi; // humidity (e.g. 55.0)
int last_pwr;
// Only needed for relay forwarding — value is already in the global
void HomeKitWrite(int dev, int var, float val) {
if (dev == 0 && var == 0) {
int pwr;
pwr = 0;
if (val > 0.0) { pwr = 1; }
if (pwr != last_pwr) { tasm_power = pwr; last_pwr = pwr; }
}
}
void EverySecond() {
// Receive sensor values via UDP
if (udpReady("btemp")) { btemp = udpRecv("btemp"); }
if (udpReady("bhumi")) { bhumi = udpRecv("bhumi"); }
// global variables auto-broadcast on assignment — no explicit udpSend needed
}
int main() {
mh_pwr = 0.0; mh_hue = 0.0; mh_sat = 0.0; mh_bri = 50.0;
elamp = 0.0; btemp = 22.0; bhumi = 50.0;
last_pwr = -1;
hkSetCode("111-11-111");
hkAdd("Light", HK_LIGHT);
hkVar(mh_pwr); hkVar(mh_hue); hkVar(mh_sat); hkVar(mh_bri);
hkAdd("Corner Light", HK_OUTLET); hkVar(elamp);
hkAdd("Temperature", HK_TEMPERATURE); hkVar(btemp);
hkAdd("Humidity", HK_HUMIDITY); hkVar(bhumi);
hkStart();
return 0;
}
Pairing¶
- Compile and flash firmware with
USE_HOMEKIT - Compile and upload TinyC program using
hkSetCode()/hkAdd()/hkStart() - Scan QR code at
http://<device>/hkwith iPhone - After configuration changes, run
hkReset()once, then re-pair
Predefined File Constants¶
Shorthand constants for fileOpen():
| Constant | Value | Description |
|---|---|---|
r |
0 | Read |
w |
1 | Write |
a |
2 | Append |
int f = fileOpen("/data.csv", r); // instead of fileOpen("/data.csv", 0)
f = fileOpen("/log.txt", a); // instead of fileOpen("/log.txt", 2)
Plugin Query (Binary Plugins)¶
Query loaded binary plugins (PIC modules) for data.
| Function | Description |
|---|---|
int pluginQuery(char dst[], int index, int p1, int p2) |
Call plugin at index with parameters p1, p2. Result string copied to dst. Returns string length |
Cross-VM Share Table (ESP32)¶
A driver-global named key/value store, mutex-protected, that lets two or more TinyC slots share scalars and short strings. Use it when one program outgrows a single slot (TC_MAX_PROGRAM = 128 KB) and is split across slots, or when multiple cooperating programs need to exchange state without going through MQTT or the filesystem.
Capacity (override via user_config_override.h): TC_SHARE_MAX = 32 entries · TC_SHARE_KEY_LEN = 16 char key · TC_SHARE_STR_LEN = 64 char value. Worst-case footprint ≈ 2.6 KB DRAM. Mutex is created lazily on first use.
| Function | Description |
|---|---|
void shareSetInt(char key[], int v) |
Set integer value for key (creates entry if missing, overwrites type) |
void shareSetFloat(char key[], float v) |
Set float value for key |
void shareSetStr(char key[], char v[]) |
Set string value for key (truncated to TC_SHARE_STR_LEN) |
int shareGetInt(char key[]) |
Read integer; 0 if key missing or wrong type |
float shareGetFloat(char key[]) |
Read float; 0.0 if missing |
int shareGetStr(char key[], char dst[]) |
Read string into dst; returns chars copied, 0 + empty dst if missing |
int shareHas(char key[]) |
1 if key exists, 0 if not |
int shareDelete(char key[]) |
Delete entry; returns 1 if it existed, 0 otherwise |
Key constraint: every key argument must be a string literal (resolved to a constant-pool index at compile time). Variable keys are not supported. Keys are case-sensitive.
Missing-key semantics: reads never raise an error. Use shareHas() to distinguish "key absent" from "key exists with value 0". Re-shareSet* with a different type silently rewrites the entry.
Example — slot 0 writer + slot 1 reader:
// slot 0 (writer)
int counter = 0;
void EverySecond() {
counter = counter + 1;
shareSetInt("counter", counter);
shareSetFloat("kwh", counter * 0.1);
char nm[32];
sprintf(nm, "tick=%d", counter);
shareSetStr("name", nm);
}
int main() { return 0; }
// slot 1 (reader)
void Command(char cmd[]) {
if (strcmp(cmd, "ALL") == 0) {
int c = shareGetInt("counter");
float f = shareGetFloat("kwh");
char n[32];
shareGetStr("name", n);
char r[160];
sprintf(r, "counter=%d kwh=%.1f name=%s", c, f, n);
responseCmnd(r);
} else {
responseCmnd("RDR: ALL");
}
}
int main() { addCommand("RDR"); return 0; }
Debug¶
| Function | Description |
|---|---|
dumpVM() |
Dump VM state to console |
Multi-VM Slots (ESP32)¶
On ESP32, up to 6 independent TinyC programs can run simultaneously in separate VM slots. Each slot has its own bytecode, globals, stack, heap, and output buffer. Memory is allocated dynamically — empty slots cost zero bytes, and non-autoexec slots use lazy loading (only ~33 bytes until first run). ESP8266 supports only 1 slot.
Slot Configuration¶
Slot assignments and autoexec flags are stored in /tinyc.cfg on the filesystem. This file is created and updated automatically whenever a program is loaded, uploaded, or the autoexec flag is toggled. There is no need to edit it manually.
Example /tinyc.cfg:
Each line corresponds to a slot (0–5): filename,autoexec_flag. The last line _info,<0|1> controls whether debug status rows are shown on the Tasmota main web page.
Tasmota Commands¶
All commands default to slot 0 if no slot number is given (backward-compatible).
| Command | Description |
|---|---|
TinyC |
Show status for all slots (JSON) |
TinyCRun [slot] [/file.tcb] |
Run slot (optionally load file first) |
TinyCStop [slot] |
Stop slot |
TinyCReset [slot] |
Stop and reset slot |
TinyCExec <n> |
Set instructions per tick (default 1000) |
TinyCInfo 0\|1 |
Show/hide VM debug rows on main web page |
TinyC ?<query> |
Query global variables by index (see below) |
Examples:
TinyCRun → run slot 0
TinyCRun /weather.tcb → load file into slot 0 and run
TinyCRun 2 /logger.tcb → load file into slot 2 and run
TinyCStop 1 → stop slot 1
TinyCReset 3 → reset slot 3
TinyCInfo 1 → show debug info on main page
Web Console (/tc)¶
The TinyC console page at /tc shows a compact overview of all slots:
- Status indicator: green dot = active (running or callback-ready), orange = loaded but not running, grey = empty
- Run / Stop buttons: context-aware — Run is greyed out when active, Stop is greyed out when idle
- A button: toggles auto-execute on boot (green = enabled). Saved to
/tinyc.cfgimmediately - Load Program: file selector with slot dropdown to load any
.tcbfile into any slot - Repository: if
/tinyc_repo.cfgexists on the filesystem, a remote program repository is shown (see below) - Upload Program: file upload with slot dropdown to upload and load a
.tcbfile directly
Program Repository¶
TinyC supports downloading pre-compiled .tcb programs from a remote repository directly on the device.
Setup:
1. Create a file /tinyc_repo.cfg on the device filesystem containing the base URL of the repository (one line):
index.txt file listing available .tcb files (one filename per line):
3. The .tcb files must be accessible at <base_url>/<filename>
Usage:
When /tinyc_repo.cfg is present, the TinyC console page shows an additional Repository fieldset with:
- A dropdown listing all .tcb files from the remote index.txt
- A slot selector
- A Download & Load button that downloads the selected file to the device filesystem and loads it into the chosen slot
The default repository at https://raw.githubusercontent.com/gemu2015/Sonoff-Tasmota/universal/tasmota/tinyc/bytecode contains example programs for sensors, displays, charts, and more. Upload the provided tinyc_repo.cfg to your device to enable it.
API Endpoints¶
The JSON API at /tc_api supports a slot parameter:
GET /tc_api?cmd=run&slot=2 → run slot 2
GET /tc_api?cmd=stop&slot=1 → stop slot 1
GET /tc_api?cmd=status → status of all slots
POST /tc_upload?slot=3&api=1 → upload .tcb to slot 3 (JSON response)
Variable Query — _Q() Macro (Google Charts)¶
TinyC global variables can be queried via HTTP as JSON, enabling live dashboards with Google Charts or any JavaScript charting library.
The _Q() macro is expanded at compile time inside string literals. The compiler resolves variable names to their index and type, so the binary contains no variable names — only compact index-based queries.
Syntax: _Q(var1, var2, ...)
The compiler replaces _Q(...) with an index-encoded query string:
- <index>i — int scalar
- <index>f — float scalar
- <index>s<n> — char[n] string
- <index>I<n> — int array of n elements
- <index>F<n> — float array of n elements
Example: Given globals float temperature; int counter;, the string:
Response format: JSON array in the order requested:
Usage in WebPage callback:
float temperature = 23.5;
int counter = 0;
void WebPage() {
webSend("<script>fetch('/cm?cmnd=TinyC+%3F_Q(temperature,counter)')");
webSend(".then(r=>r.json()).then(d=>{var v=d.TinyC;");
webSend("// v[0]=temperature, v[1]=counter");
webSend("});</script>");
}
For a specific slot, prefix with the slot number:
Boot Sequence¶
On boot, TinyC reads /tinyc.cfg and:
1. Loads each configured .tcb file into its slot
2. Auto-runs slots that have the autoexec flag set (1)
If no /tinyc.cfg exists (first boot), no programs are loaded.
Resource Usage¶
Each VM slot uses approximately 3.2 KB RAM (struct only, without program bytecode). Slots are allocated dynamically — only active slots consume memory. The slot pointer array itself is just 24 bytes. Non-autoexec slots use lazy loading: only the filename (~33 bytes) is stored until the slot is first run.
| Resource | Cost |
|---|---|
| Pointer array | 24 bytes (6 pointers) |
| Per-slot struct | ~3.2 KB |
| Program bytecode | variable (malloc'd) |
| Heap (large arrays) | 32 KB max, allocated on demand |
| Autoexec stagger | 100 ms delay between starts |
Callbacks with Multiple Slots¶
Each slot receives its own callbacks independently:
EverySecond(),Every100ms(),Every50ms()— dispatched to all active slotsWebCall()— each slot can add its own sensor rows to the main pageJsonCall()— each slot appends its own telemetry dataTaskLoop()— runs in slot's own FreeRTOS task (ESP32)CleanUp()— called on all slots before device restart
Shared resources (UDP, SPI, file handles) are global — only one slot should use each at a time.
Example: Two Programs Side by Side¶
Slot 0 — Temperature monitor:
int temp = 0;
void EverySecond() { temp = tasm_analog0; }
void WebCall() {
char buf[64];
sprintf(buf, "{s}Temperature{m}%d{e}", temp);
webSend(buf);
}
int main() { return 0; }
Slot 1 — Uptime counter:
int uptime = 0;
void EverySecond() { uptime++; }
void WebCall() {
char buf[64];
sprintf(buf, "{s}Uptime{m}%d s{e}", uptime);
webSend(buf);
}
int main() { return 0; }
Both display their sensor rows on the Tasmota main page simultaneously.
VM Limits¶
| Resource | ESP8266 | ESP32 | Browser | Notes |
|---|---|---|---|---|
| Stack depth | 64 | 256 | 256 | Operand stack entries |
| Call frames | 8 | 32 | 32 | Maximum recursion / call depth |
| Locals per frame | 256 | 256 | 256 | Scalars + small arrays ≤16 inline |
| Global variables | 64 | 256 | 256 | Scalars + small arrays ≤16 inline |
| Code size | 4 KB | 128 KB | 64 KB | Bytecode; ESP32 spills to PSRAM on DRAM OOM |
| Heap memory | 8 KB | 32 KB | 64 KB | For arrays >16 elements (auto alloc) |
| Heap handles | 8 | 32 | 32 | Max simultaneous heap allocations |
| Constant pool | 32 | 1024 | 65536 | String & float constants (DRAM, spills to PSRAM on ESP32) |
| Instruction limit | 1M | 1M | 1M | Safety limit per execution |
| GPIO pins | 40 | 40 | 40 | Pins 0–39 (simulated in browser) |
| File handles | 4 | 4 | 8 | Simultaneously open files |
| VM slots | 1 | 6 | 1 | Simultaneous programs |
| Cross-VM share | n/a | 32 keys | n/a | Driver-global shared scalar/string table (ESP32 only) |
ESP32 PSRAM fallback (since v1.3.19): TC_MAX_PROGRAM raised 64 KB → 128 KB. The bytecode buffer (s->program) and the constant data pool (vm->const_data) are allocated from internal DRAM first; on OOM they automatically spill to heap_caps_malloc(MALLOC_CAP_SPIRAM). Small/normal scripts stay in fast static RAM; only edge-case 100+ KB programs spill to PSRAM. An AddLog INFO line is emitted when the PSRAM path is taken.
Device File Management (IDE)¶
IDE Installation¶
The IDE file (tinyc_ide.html.gz) can reside on either the flash filesystem or the SD card — whichever is mounted as the user filesystem (ufsp). Upload tinyc_ide.html.gz via the Tasmota Manage File System page.
Note: TinyC scripts and data files (
.tc,.tcb, etc.) are also stored on the user filesystem (ufsp).
File Operations¶
The IDE toolbar includes controls for managing files on the Tasmota device filesystem:
- Device Files dropdown — Lists all files on the device. Select a file to load it into the editor. The list shows filename and size (e.g.
config.tc (1.2KB)). - Save File button — Saves the current editor content as a file on the device. Prompts for a filename (defaults to the current filename).
- Auto-refresh — The file list refreshes automatically when the device IP is entered or changed, and after each save.
All file operations use the /tc_api endpoint with CORS support, so the IDE can be used from any browser — it doesn't need to be served from the device.
API Endpoints¶
| Endpoint | Method | Description |
|---|---|---|
/tc_api?cmd=listfiles |
GET | Returns JSON list of files: {"ok":true,"files":[{"name":"x","size":123},...]} |
/tc_api?cmd=readfile&path=/name |
GET | Returns file content as plain text |
/tc_api?cmd=readfile&path=/name@from_to |
GET | Returns time-filtered CSV data (see below) |
/tc_api?cmd=writefile&path=/name |
POST | Writes POST body to file, returns {"ok":true,"size":N} |
/tc_api?cmd=deletefile&path=/name |
GET | Deletes a file from the filesystem |
Time-Range Filtered File Access¶
Append @from_to to the file path to extract only rows within a timestamp range from a CSV data file. This is useful for serving IoT time-series data to chart libraries.
URL format:
Example:
Both German (DD.MM.YY HH:MM) and ISO (YYYY-MM-DDTHH:MM:SS) timestamp formats are supported. The _ (underscore) separates the from and to timestamps.
Response: The header line (first line) is always included, followed by only those data lines whose first-column timestamp falls within [from..to]. Lines past the end timestamp are skipped efficiently (early break).
Performance optimization: If an index file exists (same name with .ind extension, containing timestamp\tbyte_offset lines), byte offsets are used to seek directly to the start position. Otherwise, an estimated seek is performed based on the file's first and last timestamps (similar to Scripter's opt_fext).
Port 82 Download Server (ESP32)¶
For large database files, the time-filtered readfile on port 80 can block the main web server loop. TinyC includes a dedicated port 82 download server that serves files in a FreeRTOS background task, keeping the device responsive during large transfers.
URL format:
Examples:
Features:
- Runs in a dedicated FreeRTOS task (pinned to core 1, priority 3)
- Does not block the main Tasmota loop or web interface
- Supports the same @from_to time-range filtering as the /tc_api readfile
- Uses chunked transfer encoding for filtered responses
- Content-Disposition header for browser download
- One download at a time (returns HTTP 503 if busy)
- Automatic MIME type detection (.csv/.txt = text/plain, .html, .json)
- The port can be changed by defining TC_DLPORT before compilation (default: 82)
Typical Workflow¶
- Enter device IP in the toolbar
- The Device Files dropdown auto-populates with all files on the device
- Select a file to load it into the editor — or write new code
- Click Save File to store the source on the device (e.g. as
myapp.tc) - Click Run on Device to compile, upload the
.tcbbinary, and start execution
This lets you keep TinyC source files on the device alongside their compiled bytecode, making it easy to edit programs directly without needing local file storage.
Keyboard Shortcuts (IDE)¶
| Shortcut | Action |
|---|---|
| Ctrl + Enter | Compile |
| Ctrl + Shift + Enter | Compile & Run |
| Ctrl + S | Save file |
| Ctrl + O | Open file |
| Ctrl + F | Find |
| Enter (in Find) | Find next |
| Shift + Enter (in Find) | Find previous |
| Escape | Close Find bar |
| Tab (in editor) | Insert 4 spaces |
Examples¶
The IDE includes 19 ready-to-use examples in the "Load Example..." dropdown — from basic blink to weather station receivers and interactive WebUI dashboards.
Hello World¶
LED Blink¶
#define LED 2
#define INPUT 0x01
#define OUTPUT 0x03
#define INPUT_PULLUP 0x05
#define INPUT_PULLDOWN 0x09
int main() {
gpioInit(LED, OUTPUT);
while (true) {
digitalWrite(LED, 1);
delay(500);
digitalWrite(LED, 0);
delay(500);
}
return 0;
}
Fibonacci¶
int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
int main() {
for (int i = 0; i < 10; i++) {
print(fib(i));
}
return 0;
}
String Operations¶
int main() {
char greeting[32] = "Hello";
char name[16] = "World";
char buf[64];
// Classic function style
strcpy(buf, greeting);
strcat(buf, ", ");
strcat(buf, name);
strcat(buf, "!\n");
printString(buf); // Hello, World!
// Same thing with + operator
buf = greeting;
buf += ", ";
buf += name;
buf = buf + "!\n";
printString(buf); // Hello, World!
// Formatted strings
char line[64];
sprintf(line, "count = %d", 42);
printString(line); // count = 42
// Multi-value with sprintfAppend
char report[128];
sprintf(report, "Sensor %d", 1);
sprintfAppend(report, " name=%s", name);
sprintfAppend(report, " temp=%.1f", 23.5);
printString(report); // Sensor 1 name=World temp=23.5
return 0;
}
Bubble Sort¶
void bubbleSort(int arr[], int n) {
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
int main() {
int data[8] = {64, 34, 25, 12, 22, 11, 90, 1};
bubbleSort(data, 8);
for (int i = 0; i < 8; i++) {
print(data[i]);
}
return 0;
}
WebUI Dashboard¶
int power;
int brightness;
int mode;
void WebUI() {
int page = webPage();
if (page == 0) {
webButton(power, "Power");
webSlider(brightness, 0, 100, "Brightness");
}
if (page == 1) {
webPulldown(mode, "Mode", "Off|Auto|Manual");
}
}
int main() {
webPageLabel(0, "Controls");
webPageLabel(1, "Settings");
brightness = 50;
return 0;
}
Differences from Standard C¶
| Feature | Standard C | TinyC |
|---|---|---|
| Pointers | Full support | Not supported |
| Structs | Full support | Supported: scalar fields, member access, initializer lists, compound assign. No nested structs, no union, no bit-fields |
| Enums | Full support | Supported: named/anonymous, negative values, auto-increment, inline in functions |
| Dynamic memory | malloc/free | Auto heap for arrays >16 elements (no explicit malloc) |
| Multi-dimensional arrays | int a[3][4] |
Not supported |
| String type | char* |
char arr[N] only — no pointer arithmetic |
| Preprocessor | Full CPP | #define (constants + function-like macros), #ifdef/#ifndef/#if/#else/#endif/#undef (no #include) |
| Header files | #include |
Not supported |
| typedef | Full support | Supported: primitive aliases, named struct aliases, anonymous struct typedef, chained aliases, local typedef |
const |
Type enforced | Accepted (documentation hint, not enforced at runtime) |
static locals |
Full support | Supported: zero-initialised, persists across calls. Non-zero initialisers not emitted |
| sizeof | Full support | Compile-time only: sizeof(type) and sizeof(name) supported; sizeof(expr) not supported. See sizeof Operator |
Ternary operator ?: |
Full support | Supported, including nested ternary |
| do-while | Full support | Supported |
| Compound assignments | Full support | Supported: += -= *= /= %= &= \|= ^= <<= >>= |
Hex escape \xNN |
Full support | Supported in string and char literals |
| goto | Full support | Not supported |
| Function pointers | Full support | Not supported |
| Variadic user functions | va_list etc. |
Not supported (only sprintf/sprintfAppend accept multiple args via compile-time expansion) |
| Standard library | stdio, stdlib | Built-in functions only (see Built-in Functions) |
Generated from TinyC source — lexer.js, parser.js, codegen.js, opcodes.js, vm.js