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
}
}
Out-of-band writes are tracked too. When a watched global is written from outside the script — webButton / webSlider (?sv=N_V URL), the future MQTT setvar bridge, or a UDP-global update — the firmware mirrors what STORE_WATCH would have done: bumps the shadow and sets the written-flag. So written(var) fires correctly inside EverySecond and changed(var) reports the right delta. This makes the natural pattern below work end-to-end:
watch int target; // bound to a webSlider
void EverySecond() {
if (written(target)) {
addLog("user dragged slider — sending setpoint");
// ... act on new value ...
snapshot(target);
}
}
void WebCall() { webSlider(target, 20, 28, "Target"); }
(Mechanism: at load time, the VM scans bytecode for STORE_WATCH and records each watched var-slot. The URL handler checks that index before raw-writing. TC_MAX_WATCH = 16 watched globals per slot.)
Shared Variables (UDP) — the global keyword¶
A scalar global declared with the global keyword is automatically shared with other Tasmota devices over UDP multicast — the direct equivalent of Scripter g: variables. Assigning it auto-broadcasts the new value; the firmware auto-updates it in place when a matching named value arrives from another device. No explicit udpSend/udpRecv calls are needed (those remain available for arrays/strings/manual control — see UDP Multicast).
global int mh_pwr; // write -> broadcast on the network as "mh_pwr"
global float btemp; // auto-updated when another device broadcasts "btemp"
void EverySecond() {
mh_pwr = 1; // broadcasts mh_pwr=1
matterSetFloat(ep, CLUSTER_TEMP, 0, btemp, 100); // uses the latest received btemp
}
- Only scalar globals (
int/float) can beglobal; the variable name is the shared key (matches the Scripterg:<name>). - Multicast group
239.255.255.250:1999; the socket auto-initialises on first use (see UDP Multicast for the wire protocol +UdpCall()). - Combine with the other storage keywords:
global watch int x;to also detect inbound changes (written(x)/changed(x)fire on UDP updates), orglobal persist float y;to also survive reboot.
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() |
responseCmnd(buf)length cap: the char-array form is copied through a stack buffer ofTC_RESPONSE_MAXbytes (default 512 on ESP32, 256 on ESP8266;#ifndef-guarded — raise it inuser_config_override.h). Output longer than the cap is truncated; since that usually cuts JSON mid-object Tasmota then shows an empty{}. As of 1.6.9 a truncation logsTCC: responseCmnd output truncated at N chars …(no longer silent). For very large responses, split into multiple commands. The string-literal form has no such cap (bounded only by Tasmota's ~700 BRESPONSE_MAX_SIZE).
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 |
tasm_rule |
int | read/write | Rule1 enabled (bit 0 of Settings->rule_enabled). Read returns 0 or 1. Write any non-zero to enable, 0 to disable. Equivalent to the console Rule1 1 / Rule1 0 commands. Note: some Tasmota subsystems (SML descriptors) check this flag at init and silently skip when Rule1 is disabled — flip with tasm_rule = 1 before starting them. |
tasm_lat |
float | read/write | Device latitude in decimal degrees (e.g. 48.137). Backed by Settings->latitude (stored ×1 000 000 as int). Used by tasm_sunrise / tasm_sunset calculations. |
tasm_lon |
float | read/write | Device longitude in decimal degrees (e.g. 11.575). Backed by Settings->longitude. |
tasm_maxblock |
int | read | Largest contiguous free heap block in bytes (ESP32 only) — diagnoses heap fragmentation: free heap can be high while maxblock is low |
tasm_frag |
int | read | Heap fragmentation 0..100 % (ESP32 only) — derived from 1 - maxblock/free_heap |
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 |
2D Arrays (since 1.3.38)¶
Two-dimensional arrays for char, int, and float work like in
standard C, with row-major flat storage in the heap:
char names[7][16]; // 7 rows × 16 cols (char → heap)
int ltab[5][4]; // 5 rows × 4 cols (int → heap)
float coef[3][2]; // 3 rows × 2 cols (float → heap)
int main() {
// Element access
ltab[2][3] = 42;
int v = ltab[r][c];
float k = coef[i][j];
// String ops on char rows
strcpy(names[0], "Sonntag");
strcat(names[1], " ergänzt");
int eq = strcmp(names[0], names[1]);
// Pass a row to a function expecting a 1D array of the same type
show_row_int(ltab[3], 4);
show_row_str(names[i]);
// sprintf %s with a 2D char row works with constant or variable index
char buf[64];
sprintf(buf, "name=%s len=%d", names[i], strlen(names[i]));
return 0;
}
void show_row_int(int row[], int n) { /* row is the i-th row of the caller */ }
void show_row_str(char s[]) { addLog(s); }
Memory & limits:
- Total flat size =
rows × cols. Subject to the same heap caps as 1D arrays in the table above.char buf[8][32]= 256 elements (heap). - Row references require heap storage. Auto-promotion happens at
16 total elements (the regular 1D threshold), so any practical 2D size qualifies. If you write a tiny 2D like
char buf[2][3](= 6 elements, stays inline) and tryfunc(buf[0]), the compiler emits a clear error — make the array bigger or assemble the row manually. buf(no index) passed to a function expecting that type's array is treated as the entire flat data (lengthrows × cols).buf[i](one index) passed to a function expecting a 1D array is the i-th row (lengthcols).buf[i][j]is one element.
Limitations:
- 3D and higher dimensions are not supported. Use a 2D array with manual stride math for the few cases that need it.
- Mixed-type promotion in 2D element expressions follows the same rules as 1D — int↔float coercion happens automatically.
- Initialiser literals for 2D are not yet accepted (
int m[2][3] = {{1,2,3},{4,5,6}};currently errors). Initialise inmain()with a loop or per-element assignments.
Internals: the runtime is unchanged — the compiler flattens
buf[i][j] to buf[i*cols + j] at the existing 1D heap-array opcodes,
and emits an offset-bearing reference (ADDR_HEAP_OFF) for buf[i]
in row-passing contexts. So 2D is purely an ergonomic layer on top of
the 1D heap; no new VM features.
Structs (since 1.4.0)¶
Structs are records — composite values that group named fields. TinyC structs follow C-style by-value semantics: copying a struct copies all fields, passing one to a function gives the callee its own copy.
Definition¶
struct Point {
int x;
int y;
}
struct WriteLog {
int addr;
int val;
int ms;
char src;
}
struct Sample {
int duration_ms;
float ratio;
char label[16]; // char-array field
}
struct Rect {
Point tl; // nested struct field
Point br;
}
structkeyword introduces a type definition.- Field types:
int,float,char, fixed-size 1D arrays of those, or nested struct types declared earlier in the file. - Trailing semicolon after
}is optional.
Variables and access¶
Point p; // local, zero-initialized
Point q = {3, 4}; // positional initializer
Point arr[5]; // array of struct
WriteLog g_w; // global
p.x = 10;
int v = p.y;
arr[i].x = i * 10;
g_w.src = 'O';
strcpy(g_w_or_other.label, "boost");
// Nested
Rect r;
r.tl.x = 0;
r.br.y = 200;
The struct keyword can be omitted when declaring a variable of an
already-defined type (Point p; is equivalent to struct Point p;).
Whole-struct assignment¶
Point a; a.x = 5; a.y = 7;
Point b;
b = a; // field-by-field copy
WriteLog ev;
ev.addr = 0x40; /* etc */
wlog[i] = ev; // var → array element
WriteLog x;
x = wlog[3]; // array element → var
The compiler unrolls the copy at compile time (one LOAD/STORE pair
per slot). For a 4-slot WriteLog: 8 ops + a temp for the offset/value
order. No new VM opcodes.
Functions¶
Structs are passed by value — the callee gets its own copy. Mutations to a struct param do NOT propagate back to the caller.
void log_write(WriteLog w) {
char m[80];
sprintf(m, "[%d] addr=%d val=%d", w.ms, w.addr, w.val);
addLog(m);
}
log_write(wlog[5]); // passes a copy
Returning a struct also works — the callee pushes all field values, and the caller's local-decl initializer pops them via a per-function temp slot:
WriteLog make_obs(int addr, int val) {
WriteLog w;
w.addr = addr;
w.val = val;
w.ms = millis();
w.src = 'O';
return w;
}
WriteLog x = make_obs(0x40, 0xFF00);
sizeof(StructTag)¶
Returns the slot count at compile time:
int n = sizeof(Point); // 2
int m = sizeof(Sample); // 18 (1 + 1 + 16)
int r = sizeof(Rect); // 4 (2× Point inner struct = 2+2)
sizeof(int) / sizeof(float) / sizeof(char) return 1 (the slot count
in TinyC's int32 model). Note: sizeof(int) requires the int keyword,
which the parser accepts only in this position.
Memory & layout¶
Each field occupies a fixed number of int32 slots:
| Field type | Slots |
|---|---|
int, char, bool |
1 |
float |
1 |
int arr[N] |
N |
char name[N] |
N (one byte per slot, low 8 bits) |
float ys[N] |
N |
| Nested struct | inner struct's total slot count |
Total struct slots = sum of all field slots. Nested structs flatten — a
Rect containing two Point (2 slots each) has total slot count 4
with br.x at offset 2.
Heap-promotion follows existing TinyC rules: a single struct value of ≤16 slots lives in stack/globals; any struct array totaling >16 slots auto-promotes to heap (so almost all struct arrays are on heap).
Persist (durable globals)¶
persist WriteLog wlog[16]; works — the slot count is included in the
persist hash, so adding/removing fields invalidates the .pvs file.
Limitation: the v1 hash does NOT include field-name list, so silently
reordering fields within a struct decl after persist data exists won't
invalidate. The reordered layout reads the old data with shifted offsets.
Workaround: add and remove a dummy field (which does change the hash), or
manually delete <name>.pvs. Future v2 will include field names in the hash.
Forbidden in v1¶
Each produces a clear compile-time error:
struct Node { Node next; }— self-referential structs need pointer support.struct B { struct B child; }— same as above (mutual recursion via no-pointer self).struct S { int grid[3][3]; }— 2D-array fields. Flatten or nest a struct.if (a == b)for two structs — equality not implemented; compare fields manually.Foo a = { .x = 1, .y = 2 };— designated initializers (use positional{1, 2}).
Real-world example¶
The classic "ring buffer of records" pattern:
struct WriteEvent {
int addr;
int val;
int ms;
char src;
}
WriteEvent wlog[16];
int wlog_pos = 0;
int wlog_count = 0;
void wlog_push(int addr, int val, char src) {
WriteEvent ev;
ev.addr = addr;
ev.val = val;
ev.ms = millis();
ev.src = src;
wlog[wlog_pos] = ev; // single struct copy
wlog_pos = (wlog_pos + 1) % 16;
if (wlog_count < 16) wlog_count = wlog_count + 1;
}
Compared to the pre-1.4 idiom (4 parallel arrays + 4 manual writes per
push site), structs eliminate a class of "the arrays got out of sync"
bugs entirely. See examples/structs_demo.tc for the full pattern + a
nested-struct example.
Function Pointers (since 1.4.1)¶
A function pointer holds the bytecode address of a function. Stored,
passed, and called like an int-sized value, but invoked with the same
syntax as a regular function call.
Declaring a fn-ptr type¶
Function-pointer types must be introduced via typedef. Inline
void (*p)(int); syntax is not supported in v1.
typedef int (*int_op)(int, int); // signature: int(int, int)
typedef void (*greet_fn)(char who[]); // signature: void(char[])
typedef int (*cmp_fn)(char a[], char b[]);
The typedef syntax is identical to C: typedef <retType> (*<alias>)(<params>);.
Variables, assignment, calls¶
int my_add(int a, int b) { return a + b; }
int my_mul(int a, int b) { return a * b; }
int_op op;
op = my_add; // assignment from a bare function name (no `&`)
int s = op(3, 4); // s = 7
op = my_mul; // reassign; same signature only
int p = op(3, 4); // p = 12
Three things to know:
- Bare function name is the address — no
&fnsyntax.op = &my_addwould be a parse error. Justop = my_add. - Reassignment is fine as long as the new function's signature matches the typedef. The compiler doesn't currently check this, so wrong signatures will fail at runtime in unpredictable ways.
- Forward references work — you can assign or call a function defined later in the source. Addresses are patched after the function-compile pass.
As a function parameter¶
int run_op(int_op f, int a, int b) {
return f(a, b);
}
int s = run_op(my_add, 10, 20); // 30
int p = run_op(my_mul, 10, 20); // 200
Pass the bare name; the callee receives an address-valued local.
As a global¶
greet_fn g_handler;
void hello(char who[]) {
char m[64];
sprintf(m, "Hello, %s!", who);
addLog(m);
}
int main() {
g_handler = hello;
g_handler("world");
return 0;
}
Dispatch tables (the killer use case)¶
The pattern that motivated this feature — clean command dispatch:
typedef void (*cmd_handler)(char args[]);
void do_on(char args[]) { addLog("ON"); /* ... */ }
void do_off(char args[]) { addLog("OFF"); /* ... */ }
void do_set(char args[]) { addLog("SET"); /* ... */ }
struct CmdEntry {
char name[12];
cmd_handler handler;
}
CmdEntry cmds[3];
int main() {
strcpy(cmds[0].name, "ON"); cmds[0].handler = do_on;
strcpy(cmds[1].name, "OFF"); cmds[1].handler = do_off;
strcpy(cmds[2].name, "SET"); cmds[2].handler = do_set;
return 0;
}
void Command(char cmd[]) {
for (int i = 0; i < 3; i = i + 1) {
if (strcmp(cmd, cmds[i].name) == 0) {
cmds[i].handler(cmd);
responseCmnd("ok");
return;
}
}
responseCmnd("unknown");
}
(Function-pointer fields inside structs work in 1.4.2+ — the call site
cmds[i].handler(args) routes through OP_CALL_INDIRECT correctly.)
How it works¶
A fn-ptr value is just the function's bytecode address (16-bit, fits
in an int). The compiler emits:
- Address-of:
op = my_add;→PUSH_I32 <addr>; STORE_LOCAL/GLOBAL. The 4-byte int32 holds the address in its low 16 bits. - Indirect call:
op(args)→ push args, push the var's value (LOAD_LOCAL/LOAD_GLOBAL), thenOP_CALL_INDIRECT(0x56). This new opcode pops the address from the stack, sets up a frame, and jumps — same semantics asOP_CALLminus the bytecode-embedded operand.
Frame setup is identical to a direct call, so existing RET / RET_VAL
handle returns transparently.
Out of v1¶
| Feature | Status |
|---|---|
| Function pointers as struct fields | ✅ since 1.4.2 |
Inline void (*p)(int) without typedef |
not in v1 |
&fn (explicit address-of) syntax |
not in v1 (use bare fn) |
Comparison fn1 == fn2 |
not in v1 |
| Returning a fn-ptr from a function | not in v1 |
| Anonymous function literals (lambdas) | never (no closure mechanism) |
| Signature checking on assignment | not in v1 (silent at compile time) |
Reference Parameters (since 1.4.3)¶
Function parameters declared with & after the type are passed by
reference — the callee's reads and writes go directly to the caller's
variable. Mutations are visible after the call returns.
Syntax¶
void swap(int& a, int& b) {
int tmp = a;
a = b;
b = tmp;
}
int x = 5;
int y = 7;
swap(x, y);
// x is now 7, y is now 5
The & goes after the type, before the parameter name. Same syntax as C++.
Use cases¶
Multi-out parsers — return multiple values without packaging into an array:
void parse_pair(int input, int& low, int& high) {
low = input & 0xFF;
high = (input >> 8) & 0xFF;
}
int lo = 0;
int hi = 0;
parse_pair(0xABCD, lo, hi);
// lo == 0xCD, hi == 0xAB
In-place mutation with compound operators:
void inc_by(int& n, int amount) {
n += amount; // compound assignment on a ref param works
}
int counter = 10;
inc_by(counter, 5);
inc_by(counter, 5);
inc_by(counter, 5);
// counter == 25
Globals as ref args — safe pattern for accumulators:
int g_count = 0;
int g_total = 0;
void accumulate(int& count, int& total, int sample) {
count += 1;
total += sample;
}
accumulate(g_count, g_total, 100);
accumulate(g_count, g_total, 200);
// g_count == 2, g_total == 300
What can be passed as a ref arg¶
The argument expression must be a plain variable name — a local or global. Anything else gets a clear compile error:
| Expression | Allowed? |
|---|---|
swap(x, y) — locals |
✅ |
swap(g_count, g_total) — globals |
✅ |
swap(g_count, x) — mix |
✅ |
swap(arr[i], y) — array element |
❌ (v1 not yet) |
swap(obj.field, y) — struct field |
❌ (v1 not yet) |
swap(some_int_array, y) — int[] var |
❌ (heap arrays disallowed in v1) |
For array elements and struct fields, you currently need to copy into a temporary local, pass that, then copy back. Future v2 polish will remove this restriction.
Type compatibility¶
The ref parameter's declared type must match the argument's type. Today
the compiler doesn't enforce this strictly — silent miscompilation is
possible if you pass float to int&. v1 limitation.
What about arrays?¶
Array parameters (int arr[], char buf[]) are already pass-by-
reference in TinyC — that's how they've always worked. The new & is
specifically for scalars (int, float, char). For an array, just use
int arr[] like before.
How it works (no new VM opcodes)¶
The implementation reuses the existing reference-encoding machinery:
- Caller emits
ADDR_LOCAL <slot>(orADDR_GLOBAL <gindex>) — these push an encoded reference value onto the stack. - Callee stores the encoded reference in a 1-slot local with an
internal
isScalarRefflag. - Inside the body, reading the ref param compiles to
PUSH_I8 0; LOAD_REF_ARR <slot>(load index 0 of the referenced variable). Writing compiles toPUSH_I8 0; <value>; STORE_REF_ARR <slot>.
Scalar refs are conceptually "array refs always accessed at index 0", which is why no new opcodes were needed — TinyC has had array refs since day one.
Out of v1¶
| Feature | Status |
|---|---|
int& / float& / char& |
✅ since 1.4.3 |
swap(arr[i], y) (array element as ref) |
not in v1 |
swap(obj.field, y) (struct field as ref) |
not in v1 |
| Heap-array variable as ref arg | not in v1 |
| Signature mismatch detection | not in v1 |
Reference to a struct (Point& p) |
not in v1 (use Point by value or Point arr[]) |
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. |
Variadic addLog¶
addLog accepts the same printf-style format + args as sprintf directly —
no scratch buffer needed for one-shot log lines:
addLog("boot ok"); // string literal (cheapest)
addLog("counter=%d", counter); // single int
addLog("id=%d temp=%.1f name=%s", id, temp, name); // multi-value
Internally the compiler routes the variadic form through the same sprintf
machinery, formats into a stack buffer, then emits the AddLog syscall at
LOG_LEVEL_INFO. Prefer this over the sprintf(buf, ...); addLog(buf); pair
whenever you don't need the formatted string for anything else — it's shorter
and skips the explicit buffer declaration.
addLogLevel(level, "fmt", val, ...) is the level-selectable variant
(1=ERROR / 2=INFO / 3=DEBUG / 4=DEBUG_MORE).
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') |
Higher-level string ops (since 1.5.0)¶
Beyond the C-style strcpy / strcat / strcmp / strlen /
strFind / strSub / strToken primitives, TinyC 1.5.0 adds 7
built-ins for the most common text-handling patterns. All operate
in-place on a char[] buffer; the second argument is always a
string literal (compiled to a const-pool index — runtime-needle
variants intentionally not exposed in v1).
// Find/replace all occurrences of a literal old → literal new.
// Handles both grow ("a"→"AA") and shrink ("hello"→"hi") with full
// buffer-overflow guard. Returns the number of replacements made.
int n = strReplace(buf, "old", "new");
// Prefix / suffix / substring tests. Return 1 if true, 0 if false.
// (Empty needle: startsWith returns 1, endsWith returns 1, contains returns 0.)
if (strStartsWith(cmd, "MBUS")) { /* dispatch MBUS… */ }
if (strEndsWith(name, ".tcb")) { /* it's a TinyC binary */ }
if (strContains(html, "<error>")) { /* something failed */ }
// In-place ASCII case conversion. UTF-8 multi-byte chars (>=0x80) pass through.
strToUpper(buf);
strToLower(buf);
// In-place whitespace trim (leading + trailing ' \t\n\r').
// Shifts the buffer down so buf[0] is the first non-whitespace byte.
// Returns the new length.
int newlen = strTrim(buf);
Real-world use case — config-line parsing:
char line[80] = " TARGET_TEMP = 23.4 ";
strTrim(line); // "TARGET_TEMP = 23.4"
if (strStartsWith(line, "TARGET_TEMP")) {
strReplace(line, " = ", "="); // normalize space-around-equals
strReplace(line, "= ", "=");
strReplace(line, " =", "=");
// line is now "TARGET_TEMP=23.4"
int eq = strFind(line, "=");
char value[32];
strSub(value, line, eq + 1, 99);
float v = atof(value);
}
What's NOT included in v1 (deferred — sprintf + strToken cover their niches today):
| Feature | Use what instead |
|---|---|
| Runtime-needle variants | Use literal needles or write a loop |
split returning a list |
strToken(dst, src, delim, n) |
join of an array |
strcat in a loop |
padLeft / padRight |
sprintf with %-Ns / %Ns formatters |
repeat (n × char) |
strcat in a loop, or pre-build |
| Regex | not planned |
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
#include "filename.tc"¶
Inline another .tc file at compile time (text paste, before preprocessing).
Used to share helpers between scripts:
// in sml_chart_pv.tc
#include "sml_chart_common.tc" // bring in shared chart infrastructure
#include "sml_chart_pv_common.tc" // bring in PV-specific helpers
How it works:
- The IDE loads each #included file from the project (or from the device's
filesystem when serving via /cedit), splices it textually at the directive
position, then continues with #define / #ifdef / lexer / codegen.
- Nested #include chains are followed recursively. Already-included files
are remembered so #include cycles don't blow the compile up.
- The resulting .tcb bytecode contains everything inlined — no runtime
resolution. Renaming or deleting a header after compile doesn't affect a
device running the compiled .tcb.
Paths:
- #include "foo.tc" and #include "/foo.tc" both work — leading / is
tolerated. Resolution is project-relative (IDE) or device-FS-relative (/cedit).
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 - Nested structs are supported as field type —
struct Outer { Point tl; }, access viao.tl.x. Self-referential structs (struct Node { Node next; }) are not, because they require pointers. - Function-pointer fields supported since 1.4.2 (typedef'd fn-ptr types) —
cmds[i].handler(args)routes throughOP_CALL_INDIRECT. - No 2D-array fields. No unions. No bit-fields. 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 |
int pinFree(int pin) |
Soft check: returns 1 if the pin is free to use (not claimed/forbidden by the running Tasmota config), 0 otherwise. Does not halt — lets a script gate pinMode/owSetPin/etc. on a user-configurable pin instead of crashing on a stale config. |
Fast GPIO Multiplexer (fastMux)¶
An IRAM hardware-timer ISR that steps a scan buffer of pin patterns straight on the
GPIO set/clear registers (pins 0–31) — jitter-free LED-matrix / 7-segment /
charlieplex multiplexing, far steadier than toggling pins from the VM loop. Ported
from the Scripter ESP32_FAST_MUX. Gated USE_TINYC_FAST_MUX, off by default,
dual-core Xtensa only (classic ESP32 or ESP32-S3; RISC-V C-series and ESP8266 are
excluded → the call returns -1).
| Call | Description |
|---|---|
int fastMux(0, period_us, buf, len) |
Start: configure the len GPIOs listed in buf[] as outputs and run the scan ISR every period_us µs (1 MHz timer base). Returns 0 on success, -1 if unsupported / not built. |
int fastMux(1, 0, buf, 0) |
Stop the timer + ISR. |
int fastMux(2, 0, buf, len) |
Load the scan sequence (buf[], len steps) the ISR walks to set/clear the configured pins. |
int fastMux(3, 0, buf, 0) |
Read the current scan position. |
See examples/fast_mux.tc and examples/clock_7seg.tc (a 7-segment clock) for the
scan-buffer layout.
DMX Output¶
Drive a DMX-512 universe over a GPIO (uses the RMT peripheral). Channels are
1-based, values 0..255.
| Function | Description |
|---|---|
int dmxInit(int gpio) |
Initialise DMX output on gpio. Returns 1 on success, 0 on error. |
dmxWrite(int channel, int value) |
Set DMX channel (1..512) to value (0..255). Buffered; sent on the continuous DMX refresh. |
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 fileReadBin(handle, int arr[], count) |
Read up to count int32 elements as 4-byte little-endian binary; returns elements actually read (or -1 on bad args). Works for both int[] and float[] since both are int32 in memory |
int fileWriteBin(handle, int arr[], count) |
Write count int32 elements as 4-byte little-endian binary; returns elements actually written (or -1 on bad args). Same dual-type semantics as fileReadBin — useful for chart-history persistence and similar fixed-record formats |
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(float arr[], handle [, count]) |
Read tab/comma-delimited float values into array (streamed, any size). count caps how many (default: array capacity, stops at EOF). Returns elements read |
fileWriteArray(float arr[], handle, count) |
Write count float values as tab-separated text (default 2 decimals) + trailing newline. count is explicit (like fileWriteBin) so small global arrays write the right length |
fileWriteArray(float arr[], handle, count, append) |
append=1 keeps the line open (trailing tab) so several arrays share one line |
fileWriteArray(float arr[], handle, count, append, decimals) |
decimals = max decimal places per value, trailing zeros stripped (0–7). Lower = smaller file |
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 human-readable float text separated by TAB characters, one array per line — compatible with Scripter's fra()/fwa(). The file is a plain editable .csv/.tab. count is given explicitly (like fileWriteBin/fileReadBin) because TinyC global arrays (≤64 elements) don't carry their declared size; pass the real element count so small arrays read/write the right length. The optional decimals argument (default 2) caps how many decimal places each value gets (trailing zeros are stripped, like Scripter's number precision), which keeps the file compact — important for large arrays since otherwise small/fractional values can produce long strings (e.g. 0.0001234567). Read streams value-by-value, so large arrays (e.g. a 1441-slot chart buffer) work without a line-length limit. Both int[] and float[] arrays are 32-bit slots in memory; these calls treat them as float (there is no integer variant — use fileWriteBin/fileReadBin for compact non-text storage).
// Example: Save and load float array data (human-readable .tab/.csv)
float values[5];
values[0] = 1.5; values[1] = 22.7; values[2] = 300.0;
values[3] = 4.25; values[4] = 500.5;
int f = fileOpen("/data.tab", 1); // write mode
fileWriteArray(values, f, 5); // writes "1.5\t22.7\t300\t4.25\t500.5\n"
fileClose(f);
float loaded[5];
f = fileOpen("/data.tab", 0); // read mode
int n = fileReadArray(loaded, f, 5); // 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 |
int fileRange(handle, char min[], char max[]) |
Scan the file (header auto-skipped) and write the first and last timestamps into the min / max char arrays. Returns the total row count. Use it to discover a log's span before choosing a from/to window for fileExtract. |
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 |
tasmDefer(char cmd[]) |
Queue a Tasmota command for deferred execution (runs from the 50 ms tick while the VM is halted, so the VM mutex is free). Use this for commands that must not run inside a callback — e.g. blocking ones like SendMail, or anything that could re-enter the VM. Fire-and-forget (no response). |
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...
}
}
TCP Client tuning (since 1.5.1)¶
Four per-slot helpers for production-grade outgoing TCP work. All
operate on the currently selected slot, so call tcpSelect(N)
first. Solve the recurring SMA / Solar-Edge / Powerwall idle-
disconnect pattern and the Modbus-TCP request-response boilerplate.
| Function | Description |
|---|---|
int tcpKeepalive(int idle_sec, int intvl_sec, int count) |
Enable SO_KEEPALIVE on the active slot and set TCP_KEEPIDLE / TCP_KEEPINTVL / TCP_KEEPCNT via direct setsockopt. Returns 1=ok, 0=err. Typical SMA Tripower setting: tcpKeepalive(30, 10, 3) — after 30 s idle, send up to 3 probes spaced 10 s apart before declaring dead. Solves the "peer drops idle connection after 60 s" pattern. |
tcpNoDelay(int on) |
Toggle Nagle's algorithm on the active slot. tcpConnect() already calls setNoDelay(true) by default; use this to re-enable Nagle for high-throughput bulk transfers. |
int tcpDisconnectReason() |
Returns last disconnect reason for the active slot: 0=NEVER (never connected), 1=CONNECTED (still open), 2=PEER_CLOSED (FIN), 3=TIMEOUT, 4=NETWORK (down), 5=USER_CLOSED. Lets a reconnect watchdog react intelligently to RST/FIN vs. network errors instead of blind retries. |
int tcpTransact(char req[], int req_len, char resp[], int resp_max, int timeout_ms) |
Atomic write-and-await-reply on the active slot — folds the tcpWriteArray + poll-tcpAvailable + tcpReadArray pattern into a single syscall. Returns bytes received on success (all immediately-available bytes up to resp_max); -1 timeout; -2 not connected or peer dropped mid-wait (tcpDisconnectReason() set to PEER_CLOSED); -3 bad arguments. Holds the calling slot's vm_mutex throughout — designed to run from a spawnTask worker, blocking that slot's other callbacks for ≤200 ms is fine. Suitable for protocols where the response fits in one TCP segment (Modbus-TCP, ≤256 B). |
Example — Modbus-TCP poll with one-shot request/response:
char req[12] = {0,1, 0,0, 0,6, 1, 3, 0,0x10, 0,4}; // FC03 read 4 regs
char resp[260];
void main() {
tcpSelect(0);
tcpConnect("192.168.1.50", 502);
tcpKeepalive(30, 10, 3); // SMA-style keep-alive
}
void EverySecond() {
tcpSelect(0);
int n = tcpTransact(req, 12, resp, 260, 200); // ≤200 ms
if (n > 0) {
// resp[0..n-1] = MBAP header + FC03 response
} else if (n == -2) {
int reason = tcpDisconnectReason();
if (reason == 2 || reason == 4) tcpConnect("192.168.1.50", 502);
}
}
See examples/modbus_lib.tc for the canonical mbFC03/04/06/16
helpers built on tcpTransact.
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") |
Momentary action button — pulses var to 1 on click (script reads it, acts, resets to 0). No ON/OFF suffix. Optional "Idle\|Active" label shows the Active text on the button for ~2.5 s as a click confirmation, then reverts (generic ✓ if no \|) |
webToggle(var, "label") |
Latching on/off button (0/1) — green when var≠0, grey when 0, click flips it. Optional "On\|Off" label shows different text/emoji per state (e.g. "💡 An\|🌙 Aus"); no \| → same text both states, colour only |
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 |
webRepoPulldown(var, "label", "json_url", "index_key", "/dest") |
Dropdown populated from a remote repository JSON ({ "<index_key>": [ {"label":..,"filename":..}, .. ] }). Pre-selects var, writes the chosen index back on change, and (if /dest is non-empty) downloads the picked file and saves it to /dest on the device. Handy for picking a meter descriptor or example from an online index. |
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() {
webToggle(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) {
webToggle(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 |
Chart configuration (optional, call before WebChart()):
| Function | Description |
|---|---|
WebChartSize(int width, int height) |
Set the chart <div> size in pixels (e.g. 640 × 200). 0 for either = use the default. |
WebChartTimeBase(int minutes) |
Offset the X-axis time base from "now". 0 = anchored to now (default); negative = into the past (e.g. -1440 = 24 h ago). Useful to align a ring buffer's oldest sample with the left edge. |
Customizing a chart with JS (call after WebChart()):
| Function | Description |
|---|---|
WebChartJS("…js…") |
Attach a JS snippet to the chart just emitted. It runs in the chart's draw scope with dt (Google DataTable), o (options object) and el (DOM element) — after the default options are built, before the draw. Mutate o / format dt and let TinyC draw, or take over: draw yourself and set o.done=1 to skip the default draw (lets you pick any chart type). |
This is the escape hatch for chart tweaks that don't have a dedicated builtin — colors, axis options, tooltip date format, filled-area rendering — all script-side, no firmware change. Examples:
WebChart(0, "Power 4h", "W", 0x3498db, pos, 480, arr, 0, 1, 0.0, 0.0);
// Filled area instead of a line:
WebChartJS("o.areaOpacity=0.3;new google.visualization.AreaChart(el).draw(dt,o);o.done=1");
// Locale-correct date in the tooltip (column 0 is the time/date domain):
WebChartJS("new google.visualization.DateFormat({pattern:'dd.MM.yyyy HH:mm'}).format(dt,0)");
</script>. Must be called from a web callback (e.g. WebPage()/WebCall()), like WebChart() itself.
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
Raw HTTP responses + keep-alive (since 1.6.0)¶
By default, webSend() inside a WebOn() handler routes through
Tasmota's WSContentSend which auto-emits a chunked HTML response with
Content-Type: text/html + Connection: close. That's wrong for clients
that expect specific headers (Jackery EcoTracker emu, plain JSON APIs)
or that need to keep the socket open across multiple requests (HTTP/1.1
keep-alive). The raw-mode trio bypasses WSContentSend:
| Function | Description |
|---|---|
webRawMode() |
Inside a WebOn() handler: switch the response builder to raw-bytes mode. After this, NOTHING is auto-emitted — you must write the full HTTP response yourself (status line + headers + blank line + body). |
webRawWrite(char buf[]) |
Write buf raw bytes to the underlying TCP client. Streams via the standard tc_stream_ref chunking (256 B at a time), so unlimited-size payloads work. Replaces webSend() in raw mode. |
webKeepAlive() |
Mark the response as keep-alive. The framework keeps the TCP socket open after the handler returns so the same client can send another request without reconnecting. Requires USE_HTTP_KEEPALIVE in firmware (ESP32 default). |
Example — Jackery EcoTracker emulation (exactly 3 headers, JSON body, keep-alive):
char hdr[160];
void WebOn() {
if (webHandler() != 1) return;
webRawMode();
char body[64];
sprintf(body, "{\"power\":%d,\"powerAvg\":%d,\"energyCounterIn\":%d,\"energyCounterOut\":%d}",
p, p_avg, e_in, e_out);
sprintf(hdr, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n",
strlen(body));
webRawWrite(hdr);
webRawWrite(body);
webKeepAlive();
}
int main() { webOn(1, "/"); return 0; }
This is what examples/ecotracker.tc and examples/ecotracker_shelly_emu.tc use.
No webSend() calls in the handler at all — the raw 3-header response
is byte-identical to what a real EcoTracker emits.
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.
⚠ Gotcha: Rule1 is shared with Scripter. The SML driver gates on
bitRead(rule_enabled, 0)and only runs when Rule1 is on (set viatasm_rule = 1from TinyC, or theRule1 1console command). The same bit also enables any Scripter>Ssection that's still on the device. If a legacy*.tasscript is left in flash (e.g. an old ottelo1_SML_Chart.tasor2_SML_Chart_PV.tas), it will start emitting its own chart HTML /setOnLoadCallbackregistrations alongside your TinyCWebPage()output the moment you enable SML — chart targets collide, JS callbacks overwrite each other, and the main page renders as a mess of half-drawn charts.Fix when porting from Scripter: delete the Scripter source via the IDE's Tools → Edit Script (clear the text area, save). The SML descriptor in
/sml_meter.defis independent and stays put.
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++;
}
}
Meter Setup¶
Load a meter descriptor and bind the serial pins at runtime (instead of via
GPIO template), so a single firmware build serves any meter by swapping the
/sml_meter.def file.
| Function | Description |
|---|---|
int smlScripterLoad(char path[]) |
Load the SML meter descriptor from a file (e.g. "/sml_meter.def"). Returns 1 on success. |
int smlApplyPins(char path[], int rxPin, int txPin, int flags) |
Load the descriptor and start the meter on the given rxPin/txPin. flags bit 4 (16) selects the inverted/IR-head input. Returns 1 on success. Call once from main(). |
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;
}
TWAI / CAN Bus (ESP32)¶
ESP32 has a built-in TWAI (Two-Wire Automotive Interface, electrically identical to CAN 2.0) controller — most ESP32-S3 / ESP32-C3 / ESP32-C6 boards expose it through any two GPIOs via the GPIO matrix. A 3.3 V CAN transceiver (SN65HVD230, TCAN332, TJA1051 with level shifter, etc.) is required between MCU and the differential bus pair (CAN_H / CAN_L).
| Function | Description |
|---|---|
int twaiBegin(int rx_pin, int tx_pin, int kbits, int mode) |
Install + start the TWAI driver. kbits ∈ {10, 25, 50, 100, 125, 250, 500, 800, 1000}. mode: 0=NORMAL (ACK on bus), 1=NO_ACK (self-test, useful with loopback jumper for software validation). Returns 0=ok, -1=err |
twaiEnd() |
Stop driver, release pins. Required before any subsequent twaiBegin() (driver is single-instance) |
int twaiAvailable() |
Number of RX frames waiting in the driver queue. 0 if empty |
int twaiRecv(int meta[], char data[], int max_dlc) |
Drain one RX frame. meta[0]=ID, meta[1]=ext_flag, meta[2]=dlc. data[0..dlc-1] filled with payload bytes. Returns the number of payload bytes read (0 = queue empty, < 0 = driver error) |
int twaiSend(int id, char data[], int dlc, int ext_flag) |
Transmit one frame. Returns 0=ok, -1=err. ext_flag=0 standard 11-bit ID, =1 extended 29-bit |
int twaiStatus(int counters[]) |
Snapshot of driver state. Fills counters[] with [state, tx_err, rx_err, tx_failed, rx_missed, arb_lost, bus_err]. Returns state code: 0=stopped, 1=running, 2=bus-off, 3=recovering |
int twaiFilter(int id_acc, int id_mask, int ext_flag) |
Install acceptance filter. id_acc matches RX_ID & ~id_mask; id_mask=0 accepts only the exact ID, id_mask=0x1FFFFFFF accepts all. Set BEFORE twaiBegin() for it to take effect (driver re-install required to change). Returns 0=ok |
Notes:
- Mode 1 (NO_ACK) is for software bring-up only — the controller drives TX but never expects an ACK, letting you validate the protocol stack with a TX→RX jumper on the same MCU before a transceiver is wired up.
- After a bus-off (state 2), call twaiEnd() + twaiBegin() to recover. Some drivers also support twai_initiate_recovery() via state 3 but the simpler restart is preferred from script.
- ESP32-C3 GPIO 9 is a BOOT strap pin with weak pull-up. It works as TWAI-TX but NOT as TWAI-RX (the strap holds the line and the controller sees a permanent dominant). Pick RX on a non-strap pin (10, 18, 19, etc.).
Example — sniffer that logs every frame:
int rx_meta[4];
char rx_data[8];
int rx_total = 0;
void EveryLoop() {
while (twaiAvailable() > 0) {
int n = twaiRecv(rx_meta, rx_data, 8);
if (n <= 0) break;
rx_total = rx_total + 1;
char ext = rx_meta[1] ? 'E' : 'S';
addLog("CAN RX #%d %cID=0x%X DLC=%d %02X %02X %02X %02X %02X %02X %02X %02X",
rx_total, ext, rx_meta[0], rx_meta[2],
rx_data[0], rx_data[1], rx_data[2], rx_data[3],
rx_data[4], rx_data[5], rx_data[6], rx_data[7]);
}
}
int main() {
twaiBegin(38, 39, 250, 0); // rx=38, tx=39, 250 kbit/s, NORMAL
addLog("CAN sniffer ready");
return 0;
}
See examples/slcan_bridge_tcp.tc for a full SLCAN-over-TCP bridge.
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
Raw I2S Output¶
Lower-level access to an I2S DAC/amplifier for streaming your own PCM samples (e.g. playing a WAV file chunk by chunk).
| Function | Description |
|---|---|
int i2sBegin(int mclk, int bclk, int lrclk, int dout, int sampleRate) |
Configure the I2S TX pins and sample rate (Hz). mclk=-1 for a raw I2S amp (MAX98357A/PCM5102); a codec DAC (ES8311/WM8960) needs the MCLK pin (256·fs) and its registers set over I2C from the script. Returns 0 on success, -1 on error. |
int i2sWrite(int[] pcm, int frames) |
Write frames 16-bit stereo PCM samples from the pcm array to the I2S bus (blocks until queued). Returns frames written. |
i2sStop() |
Release the I2S driver and pins. |
int fileReadPCM16(int handle, int[] pcm, int frames, int wavChannels) |
Read up to frames 16-bit samples from an open WAV file into pcm (downmixing stereo→mono when wavChannels == 2). Returns frames read (0 at EOF). Pairs with i2sWrite(). |
Microphone input (RX, ABI ≥ 7). An independent I2S RX channel on its own I2S port — separate from the audio plugin, so it works on any ESP32 with an I2S mic. For a raw MEMS mic (INMP441/ICS-43434/SPH0645) just wire the pins; for a codec mic (ES8311/ES7210) enable the codec's ADC first via the i2c* syscalls (or let the audio plugin init it) and point din at the codec's data line. 16-bit mono, master.
| Function | Description |
|---|---|
int i2sMicBegin(int mclk, int bclk, int lrclk, int din, int sampleRate) |
Open an I2S RX (mic) channel as master. mclk=-1 for a raw MEMS/PDM-on-I2S mic; a codec ADC (ES7210) needs MCLK (256·fs) + its registers set over I2C. Returns 0 on success, -1 on error. |
int i2sMicRead(int[] buf, int max) |
Read up to max 16-bit mono samples from the mic into buf. Returns the count read. |
int i2sMicLevel() |
RMS loudness 0..32767 over one ~256-sample mic block — a cheap level meter, no buffer needed. |
void i2sMicStop() |
Stop and release the mic RX channel. |
int i2sDuplexBegin(int mclk, int bclk, int ws, int dout, int din, int sampleRate) |
Open ONE full-duplex I2S channel pair (TX+RX, shared clock). Needed for a combined codec (e.g. WM8960) whose ADC is clocked by the I2S TX — a separate i2sMicBegin RX-only channel never gets a clock and stays silent. After this, i2sWrite() plays on the TX and i2sMicLevel()/i2sMicRead() read the mic at the same time. Stop with i2sStop() + i2sMicStop(). Returns 0 on success, -1 on error. |
See examples/loudness.tc.
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. |
int rgbLed(gpio, color) |
Drive a single WS2812 / NeoPixel on gpio with packed 0xRRGGBB color (use 0 to turn it off). Returns 1 on success, 0 on error. The RMT driver is created on the first call for that pin. Handy for an on-board status LED (e.g. GPIO8 on an ESP32-C6 dev board) and used by the Matter colour-light example to render Hue/Saturation/Level. |
Color format: Each array element (and rgbLed's color) 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).
Build-flag gated: the camera builtins (
cameraInit,camControl,dspLoadImageFromCam,dspImageToCam) are only compiled in when the firmware was built with-DTINYC_CAMERA(orUSE_WEBCAM). On firmware without it they don't exist — a script using them won't compile/run. ESP32-C3 (RISC-V) and most 4 MB builds without the flag have no camera.
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: firmware built with -DTINYC_HOMEKIT (which enables
USE_HOMEKIT). The HomeKit builtins (hkInit, hkAdd, hkStart,
hkStop, hkReset, hkReady, hkVar, hkSetCode) and the
HomeKitWrite callback are only compiled in with that flag — a
script using them on firmware built without it won't compile/run. By
policy the pre-built 4 MB ESP32 and C3 firmware ship without HomeKit
(see TinyC_Custom_Builds.md → Flash Budget); use an ESP32-S3 / 16 MB
build for HomeKit scripts.
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
Matter (ESP32 — requires USE_MATTER_C)¶
Matter is the alternative to HomeKit and uses the same TinyC integration
slot (TINYC_MATTER replaces TINYC_HOMEKIT at build time — they are
mutually exclusive). The pure-C matter_c engine in the firmware handles all
the hard parts — commissioning (SPAKE2+/PASE), the Interaction Model
(Read/Subscribe), and the subscription/report engine. A .tc script just
declares the Matter device and publishes attribute values — no firmware
rebuild to change the device.
Predefined Matter Constants¶
| Group | Constants |
|---|---|
| Device types | MATTER_PLUG MATTER_ONOFF_LIGHT MATTER_DIMM_LIGHT MATTER_TEMP_SENSOR MATTER_HUM_SENSOR |
| Cluster ids | CLUSTER_ONOFF CLUSTER_LEVEL CLUSTER_TEMP CLUSTER_HUM CLUSTER_POWER CLUSTER_ENERGY |
| Attribute types | MTR_BOOL MTR_U8 MTR_U16 MTR_U32 MTR_U64 MTR_ENUM8 |
Matter Functions¶
| Function | Description |
|---|---|
int matterAdd(deviceType) |
Add an endpoint of a device type; returns the endpoint id (<0 on error). The device type's mandatory clusters are attached automatically (e.g. MATTER_PLUG → OnOff → relay 1) |
matterCluster(ep, clusterId) |
Add a cluster to an endpoint |
matterAttr(ep, cl, attr, type) |
Declare an attribute (type = MTR_U32 etc.) |
matterSet(ep, cl, attr, value) |
Publish an attribute value; subscribers are notified on the next loop |
int matterGet(ep, cl, attr) |
Read back the cached attribute value (0 if absent) |
matterName(ep, "label") |
Name an endpoint so a controller shows it with that title (see Naming endpoints below) |
int matterStart() |
Advertise + accept commissioning. Returns 0=ok |
matterReset() |
Clear the data model to the root node (call before declaring your own) |
OnOff (cluster CLUSTER_ONOFF) on a plug/light endpoint maps to relay 1
automatically — the firmware applies On/Off/Toggle to the real GPIO.
Naming endpoints (matterName)¶
Plain Matter has no per-endpoint name, so a node with several endpoints appears
in Apple Home as "Temperature Sensor 1 … N". matterName(ep, "label") turns
the node into a Matter bridge so each endpoint shows with its own title: the
first call lazily adds an Aggregator endpoint, the named endpoint becomes a
Bridged Node and carries a Bridged Device Basic Information NodeLabel.
e = matterAdd(MATTER_TEMP_SENSOR);
matterName(e, "Buero Temp"); // shows as "Buero Temp" instead of "Temperature Sensor 1"
- Call it after
matterAddfor that endpoint; opt-in per endpoint (unnamed endpoints stay plain); idempotent (re-call to rename). - Labels are ASCII — a string literal stores one byte per character, so umlauts
would emit Latin-1 rather than UTF-8; use ASCII (
"Buero") and rename in the controller if you wantBüro. - Adding a bridge changes the node identity → an already-paired node must be removed and re-added in the controller to pick up the names.
Colour lights (Extended Color Light)¶
There are no named constants for colour lights yet — use the raw Matter ids:
device type 0x010D (Extended Color Light) and cluster 0x0300
(Color Control). The firmware's Interaction Model handles LevelControl
(CLUSTER_LEVEL, brightness) and ColorControl (0x0300, Hue/Saturation)
commands from a controller and applies them to the data model; your
MatterInvoke() then reads the values back with matterGet() and paints an
LED with rgbLed().
ColorControl attributes: 0 = CurrentHue, 1 = CurrentSaturation (both 0..254).
See examples/matter_rgb.tc for a complete dual-endpoint device — an
On/Off plug (relay) plus an HSV colour light on an on-board WS2812 — and
examples/rgb_selftest.tc for a controller-free colour-pipeline check.
MatterInvoke Callback (Optional)¶
Define MatterInvoke(ep, cluster, cmd) to handle controller commands yourself
(the Matter twin of HomeKitWrite). When present, your script owns the
command — the built-in OnOff→relay default steps aside, so you won't get a
double-toggle. Omit it to keep the automatic relay behavior.
void MatterInvoke(int ep, int cluster, int cmd) {
if (cluster == CLUSTER_ONOFF) {
if (cmd == 2) { tasm_power = 1 - tasmPower(0); } // Toggle
else { tasm_power = cmd; } // 0=Off, 1=On
}
}
Example — Smart Plug + Power sensor¶
int ep;
int watts, tick;
void EverySecond() {
// Real meter: watts = (int)sensorGet("ENERGY#Power"); // or smlGet("Power")
tick = tick + 1; watts = (tick * 13) % 250; // demo saw-tooth
matterSet(ep, CLUSTER_POWER, 0, watts); // ActivePower
}
int main() {
matterReset(); // clean slate (root node only)
ep = matterAdd(MATTER_PLUG); // endpoint + OnOff cluster -> relay 1
matterCluster(ep, CLUSTER_POWER); // Electrical Power Measurement
matterAttr(ep, CLUSTER_POWER, 0, MTR_U32);
matterStart(); // advertise + accept commissioning
return 0;
}
Commissioning¶
- Compile and flash firmware with
USE_MATTER_C(-DTINYC_MATTER) - Compile and upload a TinyC program using
matterAdd()/matterStart() - Pair with any on-network Matter controller (chip-tool, Apple Home, …);
the commissioning info is shown at
http://<device>/mt
Status: device-verified across all three major ecosystems. The data-model scripting API (
matter*) and theMatterInvokecallback are live; the CSA reference controller chip-tool, Apple Home, Google Home, and Amazon Alexa all commission and control the node over IPv6 (PASE → attestation → CSR → AddNOC → CASE), with the fabric persisting across reboots. Operational discovery is advertised under_matter._tcpper spec, and multi-fabric / concurrent operational sessions are supported.As of v1.6.28 the full mixed actuators + sensors bridge (
matter_home_bridge.tc) commissions and controls on Alexa too, on one node — earlier guidance to split a node into a separate lights node + sensors node for Alexa is obsolete (that limit was a stale-firmware false negative; a verified-fresh flash pairs the full bridge). Bind/Unbind + the on-device QR live athttp://<device>/mt.
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 |
int bcall(char name[], char buf[], int len) |
Call a named function exported by a loaded binary library (blib) — e.g. bcall("mb_crc16", buf, 6) to compute a Modbus CRC16 over buf. The function operates on the byte buffer and returns an int result. Requires the matching .blib to be loaded. |
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; }
shareDump() (since 1.6.2)¶
Diagnostic-only — walk the entire tc_share_table[] under the share
mutex and log every live entry via AddLog. Returns the number of
live entries to the calling VM. Pure read-only, non-allocating.
int n = shareDump();
// → Tasmota log:
// TCC: share[0] key="brutto" type=FLT value=25.290000
// TCC: share[3] key="price" type=FLT value=12.605000
// TCC: share[5] key="soc" type=INT value=87
// TCC: share[12] key="disp_html" type=STR value="<table>..."
// TCC: shareDump: 8/32 live entries
Useful for diagnosing cross-VM share anomalies: did the write actually land? at what index? with what type and value? Doc-side this avoids having to patch the firmware with strategically-placed debug logs.
dumpPersist() (since 1.6.9)¶
The persist-layer counterpart of shareDump(). Logs every persist
entry — its global index, slot count, and the raw int32 words (chunked
16/line so long arrays are never silently truncated) — plus a header
with the .pvs filename and the FNV-1a layout hash. Returns the number
of persist entries.
int n = dumpPersist();
// → Tasmota log:
// TCC: persistDump file="/bat_ctrl.pvs" hash=0x9F3A21C7 entries=12
// TCC: persist p[0] i=4 n=1 @0:42
// TCC: persist p[1] i=6 n=40 @0:1078530011,1067030938,... (16/line)
// TCC: persist p[1] i=6 n=40 @16:...
Primary use — back up before a layout-change flash. Adding/removing/
reordering ANY persist variable invalidates the whole .pvs and resets
all persist values to defaults on next boot (not just the changed
ones). Call dumpPersist() from a Command() handler, copy the log
lines, and you have a bit-exact snapshot to restore from afterwards.
Raw int32 words are emitted (not float-formatted) precisely so the
restore is bit-exact regardless of whether a slot holds an int or a
float — persist stores no per-slot type. One-shot diagnostic; heavy on
serial like shareDump().
Symmetric Crypto (ESP32)¶
AES-128 (ECB + CBC), HMAC-SHA256, SHA-256, plus hex⇄binary helpers — backed by mbedtls (already linked for HTTPS / MQTT-TLS, so no extra flash cost). All operations work in-place on TinyC char[] buffers. ESP8266 stubs return 0 / no-op.
Motivating use case: TinyC scripts speaking the Tuya local protocol (v3.3 = AES-128-ECB + CRC32) so users can drive Smart-Life-controlled devices (pool heat pumps, plugs, switches, dehumidifiers) directly from Tasmota without a cloud round-trip — see examples/pool_pump.tc. Also useful for signed REST APIs (HMAC-SHA256), encrypted SML decoders, and per-device MQTT-TLS fingerprinting.
| Function | Description |
|---|---|
int aesEcb(char key[], char data[], int enc_flag) |
AES-128-ECB on one 16-byte block in-place. key must be exactly 16 bytes. enc_flag: 1=encrypt, 0=decrypt. Returns 1=ok, 0=err. For multi-block buffers, call in a for loop over 16-byte chunks |
int aesCbc(char key[], char iv[], char data[], int len, int enc_flag) |
AES-128-CBC in-place. key and iv are 16 bytes each. len must be a multiple of 16. Stack-allocates up to 4 KB; falls back to malloc above. Returns 1=ok, 0=err |
int hmacSha256(char key[], int klen, char data[], int dlen, char out[]) |
HMAC-SHA256. key ≤ 1024 B, data ≤ 4 KB, out must be ≥ 32 B. Returns 1=ok |
int sha256(char data[], int dlen, char out[]) |
SHA-256 of data[0..dlen-1] into out[0..31]. Returns 1=ok |
int md5(char data[], int dlen, char out[]) |
MD5 of data[0..dlen-1] into out[0..15] (16-byte digest). Returns 1=ok, 0=err (incl. if MD5 is disabled in the mbedtls config). For legacy key-derivation (e.g. the Tuya BLE handshake) — not for new security designs |
int hex2bin(char hex[], int hex_len, char out[]) |
Decode hex string → bytes. Returns bytes written (= hex_len / 2). Tolerates odd hex_len by truncating the trailing nibble |
int bin2hex(char bin[], int bin_len, char out[]) |
Encode bytes → lowercase hex string. Writes bin_len * 2 chars + NUL terminator. Returns chars written (excluding NUL) |
Buffer convention: TinyC char[] is one byte per int32 slot — only the low 8 bits are used. Lengths are in bytes and must fit the ref's allocated capacity.
Limits: AES-CBC stack-allocates up to 4 KB per call. HMAC/SHA bounded at 1024 B key / 4 KB data per call — bigger payloads need to be hashed in chunks (future enhancement may expose hash-state).
Not yet exposed: AES-GCM, ECDH (Tuya v3.4 needs both — most Smart-Life devices are still v3.3 so this is rarely a blocker).
// Example — encrypt a JSON command for a Tuya v3.3 device
char key[16]; // 16-byte AES key
char body[64]; // PKCS#7-padded plaintext (JSON command)
strcpy(key, "u9eUO{aw1Kxc}uk^");
int n = strlen(body);
// pad to 16
int pad = 16 - (n & 15);
for (int i = 0; i < pad; i = i + 1) body[n + i] = pad;
n = n + pad;
// encrypt block-by-block (ECB)
for (int b = 0; b < n; b = b + 16) {
aesEcb(key, body + b, 1); // 1 = encrypt; in-place
}
// Example — verify an HMAC-SHA256 signature
char key[32];
char data[256];
char expected_sig[32]; // signature received over the wire
char actual_sig[32];
hmacSha256(key, 32, data, strlen(data), actual_sig);
int ok = 1;
for (int i = 0; i < 32; i = i + 1) {
if (actual_sig[i] != expected_sig[i]) { ok = 0; break; }
}
Bluetooth LE (ESP32)¶
Scan BLE advertisements, act as a GATT client (connect / read / write / subscribe to
notifications), or run a GATT server (advertise as a peripheral so a phone connects to you).
Built on Tasmota's common-BLE driver (xdrv_79), so it shares one radio with the
MI32 / iBeacon scanners. Requires a firmware built with USE_TINYC_BLE (which pulls in
USE_BLE_ESP32 ≈ +292 KB flash / +9 KB RAM). On a build without it, every BLE builtin is a
no-op returning a sentinel (0 / -1). ESP32 family only (no ESP8266). The first bleScan() /
bleServer() enables BLE at runtime — no SetOption115 needed.
Threading: advertisement and GATT-completion callbacks run on the NimBLE/main task, never on the
VM. They publish into small buffers; your script drains them in TaskLoop() — so the API is
non-blocking (poll, don't wait).
Scan / observe — start a scan, then pull queued adverts one at a time:
| Function | Description |
|---|---|
int bleScan(int ms) |
Start capturing adverts into a ring. ms > 0 auto-stops after that many ms; ms = 0 runs until bleScanStop(). Clears the queue. Returns 1 |
int bleScanStop() |
Stop capturing. Returns 1 |
int bleNext() |
Pop the next queued advert into the "current" slot. Returns 1 if one was available, 0 if the queue is empty. Call the getters below for the current advert |
int bleMac(char buf[]) |
Write the current advert's 6 MAC bytes into buf[0..5] (display order, MSB first). Returns 6 |
int bleAddrType() |
Address type of the current advert: 0 = public, 1/2/3 = random. Needed to connect |
int bleRssi() |
RSSI of the current advert in dBm (negative) |
int bleName(char buf[]) |
Copy the advert's local name into buf (NUL-terminated). Returns the length (0 if none) |
int bleMfg(char buf[]) |
Copy the manufacturer-specific data bytes into buf. Returns the length. The first two bytes are the company ID, little-endian (e.g. buf[0]=0xD0, buf[1]=0x06 → 0x06D0) |
GATT client — set a target, then start one read or write transaction and poll for completion. Each transaction is one connect → (optional write) → (optional subscribe-and-wait-one-notification) → disconnect:
| Function | Description |
|---|---|
int bleTarget(char mac[], int addrtype, int svc16) |
Set the GATT target: 6 MAC bytes (display order, as from bleMac()), address type (from bleAddrType()), and the 16-bit service UUID (e.g. 0x180D). Returns 1 |
int bleReadStart(int notify16) |
Connect to the target, subscribe to notify characteristic notify16 under the service, and wait for one notification. Returns 1 = started, < 0 = busy/err. Poll bleDone() |
int bleWriteStart(int chr16, char buf[], int len) |
Connect to the target and write buf[0..len-1] to characteristic chr16. Returns 1 = started, < 0 = busy/err. Poll bleDone() |
int bleDone() |
Poll the in-flight transaction: 0 = still running, > 0 = done (the value is the result length in bytes), < 0 = failed (negative xdrv_79 state code, e.g. -5 = service not found, -8 = notify timeout, -11 = connect failed) |
int bleResult(char buf[]) |
After bleDone() > 0, copy the received notification/read bytes into buf. Returns the length |
Only one GATT transaction is in flight at a time (single half-duplex slot). Devices using a random address rotate it between sessions — re-discover by manufacturer-id / name each time and connect to the address currently advertised; never hardcode the MAC.
Diagnostics: the console command BLEDebug 1 makes the common-BLE driver dump a device's actual
services + characteristics when a requested service isn't found — handy when bringing up a new device
whose UUIDs you don't know yet.
See examples/ble_scan.tc for a full scanner with a device-filter template.
// Read a notification from a BLE peripheral (service 0x180D, notify char 0x2A37)
char nm[40]; int mac[8]; char frame[40]; int st;
int main() { st = 0; bleScan(0); return 0; } // start scanning
void TaskLoop() {
if (st == 0) { // discover by name, then connect
if (bleNext()) {
bleName(nm);
if (strFind(nm, "MyDevice") >= 0) {
bleMac(mac); int t = bleAddrType();
bleScanStop();
bleTarget(mac, t, 0x180D);
if (bleReadStart(0x2a37) == 1) { st = 1; }
}
}
} else if (st == 1) { // wait for the notification
int d = bleDone();
if (d > 0) {
int n = bleResult(frame);
char m[64]; sprintf(m, "got %d bytes, b0=%02x", n, frame[0]); addLog(m);
st = 2;
} else if (d < 0) { st = 0; bleScan(0); } // failed — rescan
}
delay(250);
}
GATT server (peripheral) — advertise a service so a phone (or any BLE central) connects to the
device and exchanges data, the usual phone-app ↔ IoT pattern. Configure once (bleServer →
bleService → bleChar × N → bleServerStart), then poll/push at runtime. UUIDs are strings:
16-bit ("180a") or full 128-bit ("6e400001-…"). Characteristic properties combine with |:
| Constant | Meaning |
|---|---|
BLE_READ |
central may read the value |
BLE_WRITE |
central may write the value |
BLE_NOTIFY |
device may push notifications to a subscribed central |
| Function | Description |
|---|---|
int bleServer(char name[]) |
Begin server config; name is the advertised device name. Enables BLE at runtime. Returns 1 |
int bleService(char uuid[]) |
Set the service UUID (16- or 128-bit string). Returns 1 |
int bleChar(char uuid[], int props) |
Add a characteristic; props = BLE_READ/BLE_WRITE/BLE_NOTIFY OR-combined. Returns a handle (≥ 0) used by the calls below, or -1 |
int bleServerStart() |
Build the service and start advertising. Returns 1 |
int bleConnected() |
1 if a central (phone) is connected, else 0 |
int bleCharWritten(int h) |
Bytes the central wrote to characteristic h since the last read (0 = nothing new) |
int bleCharRead(int h, char buf[]) |
Copy those written bytes into buf, clear the pending flag. Returns the length |
int bleCharSet(int h, char buf[], int len) |
Set the value a central reads from h (no notification). Returns 1 |
int bleNotify(int h, char buf[], int len) |
Set the value and push a notification to the subscribed central. Returns 1 |
int bleServerStop() |
Stop advertising. Returns 1 |
Build the server in main(), then in TaskLoop() poll bleCharWritten() / bleCharRead() for
phone→device data and push with bleNotify() / bleCharSet(). The GATT layout is built once per
boot — to change the services/characteristics, reboot the device (re-running the script re-attaches
to the existing server). Works on any ESP32 with BLE: on the ESP32-P4 the radio is the on-board C6
over esp-hosted, on other ESP32s the native controller — the API is identical. Test with a phone app
such as nRF Connect.
See examples/ble_server.tc for a Nordic-UART-style server (phone writes RX → toggles Power1; device
notifies an incrementing counter on TX).
// GATT server: phone writes 00/01 to RX -> Power1; device notifies a counter on TX.
#define RX "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
#define TX "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
int hrx; int htx; int n; char buf[64];
int main() {
bleServer("TasmotaBLE");
bleService("6e400001-b5a3-f393-e0a9-e50e24dcca9e");
hrx = bleChar(RX, BLE_WRITE);
htx = bleChar(TX, BLE_READ | BLE_NOTIFY);
bleServerStart();
n = 0;
return 0;
}
void TaskLoop() {
if (bleCharWritten(hrx)) { // phone -> device
bleCharRead(hrx, buf);
tasmCmd(buf[0] ? "Power1 1" : "Power1 0", buf);
}
if (bleConnected()) { // device -> phone
n = n + 1; buf[0] = n & 0xff; buf[1] = (n >> 8) & 0xff;
bleNotify(htx, buf, 2);
}
delay(1000);
}
LVGL GUI (ESP32 — requires USE_TINYC_LVGL)¶
Build a retained-mode, touch-interactive GUI on the device's panel using the LVGL 9 engine
(buttons, sliders, charts, …). Built on Tasmota's xdrv_54_lvgl over Universal Display, so it draws
to the same panel as drawLine()/TinyUI and reads the same touch driver (GT911 etc.). Requires a
firmware built with USE_TINYC_LVGL (pulls in USE_LVGL, ≈ +250 KB flash + a partial draw
buffer in RAM/PSRAM). On a build without it every lvgl* builtin is a no-op returning 0. ESP32
family only.
LVGL is not re-entrant: the firmware renders it on the main loop while your lvgl* calls run on
the VM task, so a mutex serialises them for you — just call the builtins normally.
Model. Objects are addressed by an integer handle (1…). Handle 0 is the active screen —
use it as a parent (lvglLabel(0)) or to style the screen itself (lvglSetBgColor(0, …)). Objects
are auto-removed from the handle table when LVGL deletes them, so handles never dangle. There is no
callback into TinyC; instead you poll an event ring in your loop (same idea as BLE):
while (lvglEvent()) { if (lvglEventObj()==btn && lvglEventCode()==10) … }.
Colours are 0xRRGGBB. Common LVGL 9 constants you pass as plain integers:
- Align: 1=TOP_LEFT, 2=TOP_MID, 5=BOTTOM_MID, 9=CENTER.
- Event codes: 0=ALL, 1=PRESSED, 4=SHORT_CLICKED, 10=CLICKED, 11=RELEASED, 35=VALUE_CHANGED.
- Style props (lvglSetStyleInt): 120=RADIUS, 56=BORDER_WIDTH, 112=OPA.
- Chart: type 1=LINE, 2=BAR; axis 0=PRIMARY_Y.
Lifecycle & objects
| Function | Description |
|---|---|
int lvglInit() |
Start LVGL on the panel (idempotent). Returns 1 if active. Call once before any other lvgl*. |
int lvglActive() |
1 if LVGL is running, else 0 |
int lvglObj(int parent) |
Create a base container. parent = a handle or 0 (screen). Returns a handle (0 = table full) |
int lvglLabel(int parent) |
Create a label |
int lvglButton(int parent) |
Create a button (add a child label for its caption) |
int lvglDelete(int h) |
Delete an object (and its children). 1=ok |
int lvglClean(int h) |
Delete an object's children only |
Common properties
| Function | Description |
|---|---|
void lvglSetPos(int h, int x, int y) |
Absolute position within the parent |
void lvglSetSize(int h, int w, int ht) |
Size in pixels |
void lvglAlign(int h, int align, int dx, int dy) |
Align within parent (e.g. 9=CENTER) + offset |
void lvglSetText(int h, str) |
Set label / checkbox text |
void lvglSetBgColor(int h, int rgb888) |
Background colour (opaque) |
void lvglSetTextColor(int h, int rgb888) |
Text colour |
void lvglSetStyleInt(int h, int prop, int val) |
Generic int style on MAIN part (e.g. 120=RADIUS) |
Events (poll)
| Function | Description |
|---|---|
void lvglEventEnable(int h, int filter) |
Route events of code filter (0=ALL) from object h into the ring |
int lvglEvent() |
Pop the next event into "current"; 1=got one, 0=empty |
int lvglEventObj() |
Handle of the current event's object |
int lvglEventCode() |
Code of the current event (e.g. 10=CLICKED, 35=VALUE_CHANGED) |
Value widgets
| Function | Description |
|---|---|
int lvglSlider(int parent) / lvglBar / lvglArc |
Create a slider / bar / arc |
int lvglSwitch(int parent) / lvglCheckbox(int parent) |
Create a switch / checkbox |
void lvglSetValue(int h, int v, int anim) |
Set value (slider/bar/arc). anim=1 animates (arc ignores it) |
int lvglGetValue(int h) |
Current value (slider/bar/arc) |
void lvglSetRange(int h, int min, int max) |
Value range |
void lvglSetChecked(int h, int on) |
Set checked state (switch/checkbox) |
int lvglIsChecked(int h) |
1 if checked |
Chart & image
| Function | Description |
|---|---|
int lvglChart(int parent) |
Create a chart |
void lvglChartType(int h, int type) |
1=LINE, 2=BAR |
int lvglChartSeries(int chart, int rgb888) |
Add a coloured series; returns a series handle |
void lvglChartNext(int chart, int series, int v) |
Shift in the next value (scrolling) |
void lvglChartRange(int chart, int axis, int min, int max) |
Y-axis range (axis 0=PRIMARY_Y) |
void lvglChartCount(int chart, int n) |
Number of points |
int lvglImage(int parent) |
Create an image |
void lvglImageSrc(int h, str path) |
Set image source from an LVGL FS path (e.g. "A:/logo.bin", or "A:/img.png" — PNG decode is built in) |
void lvglImageAngle(int h, int deci_deg) |
Rotate the image, 0.1° units (3600 = 360°) — e.g. a clock hand |
void lvglImagePivot(int h, int x, int y) |
Set the rotation pivot, px from the image's top-left (default is image centre) |
void lvglSetFont(int h, int size) |
Set a label's font size; snapped to the built-in Montserrat sizes (10/14/20/28). |
void lvglImageScale(int h, int sx, int sy) |
Scale an image per-axis, 256 = 100% (e.g. a pulsing cover). |
int lvglLine(int parent) |
Create a line object. |
void lvglLinePoints(int h, int x1, int y1, int x2, int y2) |
Set a line's two endpoints. |
void lvglLineStyle(int h, int rgb, int width) |
Set a line's color (0xRRGGBB) and width (px). |
// Tap a button -> change a label (see examples/lvgl_demo.tc)
int lbl, btn;
int main() {
lvglInit();
lbl = lvglLabel(0); lvglSetText(lbl, "Tap me"); lvglAlign(lbl, 9, 0, -40);
btn = lvglButton(0); lvglSetSize(btn, 160, 60); lvglAlign(btn, 9, 0, 30);
lvglEventEnable(btn, 10); // 10 = CLICKED
while (1) {
while (lvglEvent()) {
if (lvglEventObj() == btn) { lvglSetText(lbl, "Tapped!"); }
}
delay(30);
}
}
Examples: lvgl_demo.tc (button → label), lvgl_widgets.tc (slider/bar/switch), lvgl_chart.tc
(live chart), lvgl_smoke.tc (bring-up test).
Debug¶
| Function | Description |
|---|---|
dumpVM() |
Dump VM state to console |
int vmStackDepth() |
Returns the current operand-stack depth. A diagnostic for catching stack leaks in scripts/callback chains — call it at the same point across loops; the value should stay constant. |
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 |
TinyCIde [url] |
Update the browser IDE from the repo (or a URL); replaces /tinyc_ide.html.gz, no file manager (needs USE_UFILESYS) |
TinyC ?<query> |
Query global variables by index (see below) |
TinyCChkpt |
Show partition table (ESP32 only) |
TinyCChkpt p |
Pack: shrink app0 to fit, expand spiffs |
TinyCChkpt p <KB> |
Pack with explicit app0 size in KB (1024..3904) |
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
TinyCChkpt → list all partitions with offsets, sizes, labels
TinyCChkpt p → auto-pack: app0 = current sketch size + 192 KB
headroom (rounded to 64 KB), all remaining
flash up to the start of `custom` (or end of
chip) becomes `spiffs`
TinyCChkpt p 2880 → set app0 to exactly 2880 KB; spiffs = rest
TinyCChkpt p — partition repack
A safe in-firmware way to reclaim flash for the filesystem on devices
that were initially provisioned with a larger app0 than the current
firmware needs. Common use case: a 16 MB device flashed with a generic
app0=3008 KB partition layout, where the actual sketch is only 1.5 MB
— the rest of the app slot is wasted, while spiffs sits at a small
default size. Packing returns the unused app space to spiffs.
Mechanics:
- Reads the current partition table from
0x8000. - Locates
app0,spiffs, and (if present)custom. Verifies asafebootpartition exists — if not, the pack is refused: no safeboot means no recovery if the new app slot turns out to be too small for a future build, so the operation is unsafe. - Computes new sizes:
app0→ requested KB (or auto: sketch + ~192 KB, 64 KB-aligned).spiffs→ start = end of new app0; end = start ofcustompartition (if any) or end of flash chip.- Updates the partition table entries, recomputes the trailer MD5,
writes the 4 KB sector at
0x8000back atomically. - Formats LittleFS — the spiffs partition's offset and size both change, so the existing FS layout is no longer valid. Anything you want to keep MUST be backed up first.
Safety:
- Refused without a
safebootpartition (no recovery → no go). - Refused if the new app size is smaller than the current sketch (would brick on next OTA).
- Refused if there's no room for spiffs (custom partition or chip end is closer than new spiffs would need).
- The
custompartition (if present) is preserved in place; spiffs expands up to its boundary, not over it.
Recommended workflow:
TinyCChkpt # see current layout — note app0 size and FS size
# → in WebUI's File Manager, back up Settings.json and any .tcb / .pvs
# you don't want to lose
TinyCChkpt p # auto-pack
# → device reboots, FS is empty; re-upload your scripts and Settings
Don't run TinyCChkpt p if you're not prepared to lose the filesystem
contents.
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) {
webToggle(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 (data) | Full support | Not supported (no int *p, no &x for scalars, no pointer arithmetic). Strings use char arr[N] instead of char*; arrays decay to pass-by-reference into function parameters; int& a reference parameters (since 1.4.3) cover the common multi-out / mutate-caller case |
| Function pointers | Full support | Supported since 1.4.1 — typedef-based: typedef int (*cmp_fn)(int,int); cmp_fn fn = my_function; fn(a, b);. As locals, globals, parameters, and struct fields (1.4.2). Out: inline void (*p)(int) decl without typedef, fn-ptr ==/!=, returning fn-ptrs |
| Structs | Full support | Supported: scalar fields, member access (including nested struct fields with correct offset arithmetic), initializer lists, whole-struct assignment, struct as parameter / return, sizeof(Tag), function-pointer fields. Out: self-referential structs (struct Node { Node next; } needs pointers), unions, bit-fields, designated initializers, struct equality a == b |
| Reference parameters | C++ feature | Supported since 1.4.3 — void swap(int& a, int& b) for scalar pass-by-reference (int, float, char). Multi-out, in-place compound (n += 5 on a ref param), globals as ref args. Caller arg must be a plain identifier of a local or global; array elements / struct fields / heap arrays yield a clear compile error |
| 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 | Full support | 2D supported since 1.3.38 — char buf[N][M], int grid[R][C], float coef[R][C]. Element access arr[i][j], row passing func(arr[i]) to 1D array params, strcpy/strcat/strcmp on rows, sprintf("%s", arr[i]) for 2D char. 3D+ not supported. 2D literal initialisers (int m[2][3] = {{1,2,3},{4,5,6}}) not accepted yet — initialise in main() instead |
| String type | char* |
char arr[N] only — no pointer arithmetic. char name[] = "literal" size-inferred (since 2026-03). String ops (replace/starts/ends/contains/upper/lower/trim) since 1.5.0 — see String Operations |
| Preprocessor | Full CPP | #define (constants + function-like macros), #ifdef/#ifndef/#if/#else/#endif/#undef, #include "file.tc" (text-paste at compile time, recursive, cycle-safe) |
| Header files | #include |
#include "file.tc" supported — text paste before preprocessing; resolution is project-relative (IDE) or device-FS-relative (/cedit) |
| typedef | Full support | Supported: primitive aliases, named struct aliases, anonymous struct typedef, chained aliases, local typedef, function-pointer typedefs (1.4.1+) |
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 |
| 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