voltmeter.tc¶
voltmeter.tc — procedurally-drawn analog voltmeter with dirty-rect needle
// voltmeter.tc — procedurally-drawn analog voltmeter with dirty-rect needle
//
// Demonstrates the TinyC canvas API (1.4.2+):
//
// 1. Allocate a blank RGB565 canvas with imgCreate(w,h) — returns slot 0..3.
// 2. imgBeginDraw(slot) redirects all dsp* primitives into the canvas.
// 3. Draw the gauge face (bezel, coloured zones, ticks, labels) with the
// normal dsp* calls — nothing appears on the panel yet.
// 4. imgEndDraw() restores the physical display as the target.
// 5. dspPushImageRect() blits the finished canvas onto the panel — once
// as background, then each tick to erase the old needle's bounding box.
// 6. dspLine draws the new needle on top.
//
// ── RESIZING ─────────────────────────────────────────────────────────
// Change ONE knob (FACE_SIZE) to resize the whole gauge. Every radius,
// tick length, label radius, needle length and pivot cap rescale
// proportionally. Font is picked automatically:
// FACE_SIZE < 240 → font 3 (5x8, compact) at scale 1
// FACE_SIZE >= 240 → font 1 (7x12, standard) at scale 1, 2, 3 …
//
// FACE_SIZE = 160 → small (fits 240x240 displays)
// FACE_SIZE = 240 → medium
// FACE_SIZE = 320 → original (fits 480x320)
// FACE_SIZE = 400 → large
// FACE_SIZE = 480 → huge (fits 800x480)
//
// Also adjust PANEL_X / PANEL_Y to place the gauge on the panel.
// Canvas memory footprint = FACE_SIZE * FACE_SIZE * 2 bytes (RGB565).
// At 320 that's ~200 KB — PSRAM required above ~180.
//
// ── VALUE RANGE & COLOUR ZONES ───────────────────────────────────────
// Independent knobs (see #defines below):
// VAL_MIN / VAL_MAX — scale endpoints (e.g. 0..100, 0..250, -50..50)
// TICK_MAJOR / TICK_MINOR — label + tick strides
// GREEN/YELLOW/RED _FROM/_TO — coloured band ranges, in VALUE units
//
// Examples:
// 0..100 default : all zones as shown
// Temperature -20..+60 : GREEN 0..40, YELLOW 40..50, RED 50..60
// Battery 0..100 inv : GREEN 60..100, YELLOW 20..60, RED 0..20
//
// Console dispatch: VMSET <n> updates the reading; EverySecond wanders.
#define FACE_SIZE 320 // square canvas — CHANGE THIS to resize
#define PANEL_X 80 // where on the screen to show the gauge
#define PANEL_Y 0
// Palette (RGB565)
#define COL_BG 0x0000 // canvas / panel background around the gauge
#define COL_BEZEL 0xBDF7 // light grey
#define COL_RIM 0x632C // darker grey ring
#define COL_FACE 0xFFFF // white inner face (labels sit on this)
#define COL_GREEN 0x3605
#define COL_YELLOW 0xFCC0
#define COL_RED 0xC1A3
#define COL_TICK 0x18E3
#define COL_LABEL 0x18E3
#define COL_NEEDLE 0xE041 // saturated red
#define COL_HUB 0x2AB9 // soft blue
// ── VALUE RANGE & TICKS ────────────────────────────────────────────
// The scale always sweeps 270° (-135° left → +135° right); VAL_MIN maps
// to the left end, VAL_MAX to the right end. Works for any range:
// positive (0..100), asymmetric (0..250), even bipolar (-50..+50).
#define VAL_MIN 0 // scale start (leftmost tick)
#define VAL_MAX 100 // scale end (rightmost tick)
#define TICK_MAJOR 10 // major tick + numeric label every N units
#define TICK_MINOR 2 // minor tick every N units
#define VAL_INIT 80 // initial needle reading
// ── COLOURED ZONES (by VALUE — can overlap or leave gaps) ──────────
// Set FROM == TO to hide a zone. All three are drawn; order is
// green → yellow → red so red always wins any overlap.
#define GREEN_FROM 0
#define GREEN_TO 50
#define YELLOW_FROM 50
#define YELLOW_TO 80
#define RED_FROM 80
#define RED_TO 100
// ── CENTRE LABEL (e.g. units) ──────────────────────────────────────
// A static text drawn into the canvas face, below the pivot. Set to ""
// to hide it entirely. Position is a percentage of R_FACE below CY,
// so it scales with the gauge.
// (GAUGE_LABEL is a char array, not a #define, because TinyC strlen()
// needs a char array — not a bare string literal.)
char GAUGE_LABEL[] = "VOLTS"; // lower-middle label; "" to hide
#define LABEL_Y_FRAC 50 // label Y = CY + R_FACE * this / 100
// Sweep geometry — VAL_MIN at 7:30, midpoint at 12, VAL_MAX at 4:30
// (270° sweep clockwise). Angles are kept as inline literals to work
// around a codegen quirk with negative-float #defines.
int face; // canvas slot
int on_x; int on_y; // previous needle endpoint
int hx_out; int hy_out; // needleXY output
int value = VAL_INIT; // current reading (VAL_MIN..VAL_MAX)
int prev_v = -99999; // last drawn value (sentinel)
// ── Derived geometry (computed from FACE_SIZE at startup) ─────────
// All values are in canvas pixels. Base ratios taken from the original
// 320px design (CX=160 → R_BEZEL=155, R_RIM=150, …); every other size
// scales linearly.
int CX; int CY;
int R_BEZEL; // outer filled bezel disc
int R_RIM; // rim ring
int R_FACE; // inner white face disc
int R_ARC_OUT; // coloured arc band outer radius
int R_ARC_IN; // coloured arc band inner radius (= tick base)
int TICK_MAJ; // major tick length (every 10)
int TICK_MIN; // minor tick length (every 2)
int R_LABEL; // numeric label centre radius
int NEEDLE_LEN; // needle pivot → tip
int HUB_R; // pivot cap radius
int NEEDLE_W; // thick-line width of the needle
int FONT_SCALE; // dsp font scale factor for tick labels (1 / 2 / 3 …)
int LABEL_OFF; // char-half-width for tick-label centring
char FONT_SPEC[16];
// Centre-label font (one step bigger/bolder than the tick labels)
int CLABEL_SCALE;
int CLABEL_CHAR_W; // approx char width for centring
int CLABEL_X; int CLABEL_Y;
char CLABEL_SPEC[16];
// All geometry is expressed as (FACE_SIZE * N) / 320 — i.e. ratios of the
// reference 320px design. Change FACE_SIZE and everything rescales.
void computeGeometry() {
CX = FACE_SIZE / 2;
CY = FACE_SIZE / 2;
R_BEZEL = (FACE_SIZE * 155) / 320;
R_RIM = (FACE_SIZE * 150) / 320;
R_FACE = (FACE_SIZE * 145) / 320;
R_ARC_OUT = (FACE_SIZE * 142) / 320;
R_ARC_IN = (FACE_SIZE * 128) / 320;
TICK_MAJ = (FACE_SIZE * 12) / 320;
TICK_MIN = (FACE_SIZE * 6) / 320;
R_LABEL = (FACE_SIZE * 108) / 320;
NEEDLE_LEN = (FACE_SIZE * 110) / 320;
HUB_R = (FACE_SIZE * 16) / 320;
NEEDLE_W = (FACE_SIZE * 3) / 320;
if (NEEDLE_W < 2) { NEEDLE_W = 2; }
// Font selection — pick the most readable font for the gauge size.
// FACE_SIZE < 240 → font 3 (5x8, compact) — small gauges
// FACE_SIZE ≥ 240 → font 1 (7x12, standard), scale up on big gauges
int font_id;
if (FACE_SIZE < 240) {
font_id = 3; // 8px tall — compact
FONT_SCALE = 1;
LABEL_OFF = 4; // char half-width in font 3
} else {
font_id = 1; // 12px tall — standard
FONT_SCALE = FACE_SIZE / 320;
if (FONT_SCALE < 1) { FONT_SCALE = 1; }
LABEL_OFF = 6 * FONT_SCALE; // char half-width × scale
}
sprintf(FONT_SPEC, "[f%ds%d]", font_id, FONT_SCALE);
// Centre-label (unit) font — always font 1, one step bigger than the
// tick labels for small gauges, same scale for big ones.
if (FACE_SIZE < 240) {
CLABEL_SCALE = 1;
CLABEL_CHAR_W = 6; // font 1 scale 1 ≈ 6px char
} else {
CLABEL_SCALE = FONT_SCALE + 1;
CLABEL_CHAR_W = 6 * CLABEL_SCALE;
}
sprintf(CLABEL_SPEC, "[f1s%d]", CLABEL_SCALE);
// Centre-label position: horizontally centred on CX, vertically below
// the pivot by LABEL_Y_FRAC% of R_FACE.
int n = strlen(GAUGE_LABEL);
CLABEL_X = CX - (n * CLABEL_CHAR_W) / 2;
CLABEL_Y = CY + (R_FACE * LABEL_Y_FRAC) / 100 - CLABEL_CHAR_W;
}
// ── geometry helper ────────────────────────────────────────────────
// Map a value in [VAL_MIN..VAL_MAX] to radians in [-2.356..+2.356]
// (i.e. -135° → +135°, a 270° clockwise sweep).
float valToRad(int v) {
float frac;
float span;
span = (float)(VAL_MAX - VAL_MIN);
if (span == 0.0) { span = 1.0; }
frac = (float)(v - VAL_MIN) / span;
if (frac < 0.0) { frac = 0.0; }
if (frac > 1.0) { frac = 1.0; }
return -2.356 + frac * 4.712; // -135° .. +135°
}
void needleXY(int v, int len) {
float rad;
rad = valToRad(v);
hx_out = CX + (int)(sin(rad) * (float)len);
hy_out = CY - (int)(cos(rad) * (float)len);
}
// thick line (copied from analog_clock pattern)
void thickLine(int x0, int y0, int x1, int y1, int color, int width) {
int d; int half;
half = width / 2;
dspColor(color, 0);
d = 0 - half;
while (d <= half) {
dspPos(x0 + d, y0); dspLine(x1 + d, y1);
dspPos(x0, y0 + d); dspLine(x1, y1 + d);
d = d + 1;
}
}
// ── face rendering (drawn ONCE into the canvas) ────────────────────
// Angular-arc band made of ~120 radial segments.
void drawArcBand(int r_out, int r_in, float a_from, float a_to, int color) {
float a;
float step;
int x1; int y1; int x2; int y2;
step = 0.02; // rad step — ~1.1°
a = a_from;
dspColor(color, 0);
while (a <= a_to) {
x1 = CX + (int)(sin(a) * (float)r_in);
y1 = CY - (int)(cos(a) * (float)r_in);
x2 = CX + (int)(sin(a) * (float)r_out);
y2 = CY - (int)(cos(a) * (float)r_out);
dspPos(x1, y1);
dspLine(x2, y2);
a = a + step;
}
}
// Radial tick at value v.
void drawTick(int v, int r_out, int length, int color) {
float rad;
int x1; int y1; int x2; int y2;
rad = valToRad(v);
x1 = CX + (int)(sin(rad) * (float)r_out);
y1 = CY - (int)(cos(rad) * (float)r_out);
x2 = CX + (int)(sin(rad) * (float)(r_out - length));
y2 = CY - (int)(cos(rad) * (float)(r_out - length));
dspColor(color, 0);
dspPos(x1, y1);
dspLine(x2, y2);
}
// Numeric label for value v at radius r. Centred to the text's pixel
// width — works for "-50", "100", "1000" and everything between.
void drawLabel(int v, int r) {
float rad;
int x; int y; int n; int text_half;
char buf[12];
rad = valToRad(v);
sprintfInt(buf, "%d", v);
n = strlen(buf);
text_half = (n * LABEL_OFF * 2) / 3; // ≈ half text width
x = CX + (int)(sin(rad) * (float)r) - text_half;
y = CY - (int)(cos(rad) * (float)r) - LABEL_OFF;
dspColor(COL_LABEL, COL_FACE);
dspPos(x, y);
dspDraw(buf);
}
void drawFace() {
int v;
// Bezel: two filled discs (outer grey, inner white) + dark rim ring
dspColor(COL_BEZEL, 0);
dspPos(CX, CY); dspFillCircle(R_BEZEL);
dspColor(COL_RIM, 0);
dspPos(CX, CY); dspCircle(R_RIM);
dspColor(COL_FACE, 0);
dspPos(CX, CY); dspFillCircle(R_FACE);
// Coloured zones — boundaries given as VALUES, not angles. Each zone
// is clipped to [VAL_MIN..VAL_MAX] by valToRad; gaps and overlaps OK.
if (GREEN_TO > GREEN_FROM) {
drawArcBand(R_ARC_OUT, R_ARC_IN,
valToRad(GREEN_FROM), valToRad(GREEN_TO), COL_GREEN);
}
if (YELLOW_TO > YELLOW_FROM) {
drawArcBand(R_ARC_OUT, R_ARC_IN,
valToRad(YELLOW_FROM), valToRad(YELLOW_TO), COL_YELLOW);
}
if (RED_TO > RED_FROM) {
drawArcBand(R_ARC_OUT, R_ARC_IN,
valToRad(RED_FROM), valToRad(RED_TO), COL_RED);
}
// Tick marks: major every TICK_MAJOR units, minor every TICK_MINOR.
v = VAL_MIN;
while (v <= VAL_MAX) {
if ((v - VAL_MIN) % TICK_MAJOR == 0) {
drawTick(v, R_ARC_IN, TICK_MAJ, COL_TICK); // major
} else {
drawTick(v, R_ARC_IN, TICK_MIN, COL_TICK); // minor
}
v = v + TICK_MINOR;
}
// Numeric labels at majors
dspText(FONT_SPEC);
v = VAL_MIN;
while (v <= VAL_MAX) {
drawLabel(v, R_LABEL);
v = v + TICK_MAJOR;
}
// Centre label (units / name) — lower half, below the pivot
if (strlen(GAUGE_LABEL) > 0) {
dspText(CLABEL_SPEC);
dspColor(COL_LABEL, COL_FACE);
dspPos(CLABEL_X, CLABEL_Y);
dspDraw(GAUGE_LABEL);
}
}
// ── needle ─────────────────────────────────────────────────────────
// Erase the old needle by blitting its bounding rect FROM the canvas back
// onto the panel.
void eraseNeedle(int hx, int hy, int thick) {
int x0; int y0; int w; int h; int margin;
margin = thick + 2;
if (CX < hx) { x0 = CX - margin; } else { x0 = hx - margin; }
if (CY < hy) { y0 = CY - margin; } else { y0 = hy - margin; }
w = abs(hx - CX) + margin * 2;
h = abs(hy - CY) + margin * 2;
// clamp to canvas bounds
if (x0 < 0) { w = w + x0; x0 = 0; }
if (y0 < 0) { h = h + y0; y0 = 0; }
if (x0 + w > FACE_SIZE) { w = FACE_SIZE - x0; }
if (y0 + h > FACE_SIZE) { h = FACE_SIZE - y0; }
if (w > 0 && h > 0) {
// source: canvas (x0,y0); dest: screen (PANEL_X+x0, PANEL_Y+y0)
dspPushImageRect(face, x0, y0, PANEL_X + x0, PANEL_Y + y0, w, h);
}
}
void drawNeedle() {
needleXY(value, NEEDLE_LEN);
// draws straight onto the panel (we're NOT in begin/end mode)
thickLine(PANEL_X + CX, PANEL_Y + CY,
PANEL_X + hx_out, PANEL_Y + hy_out,
COL_NEEDLE, NEEDLE_W);
// pivot cap — drawn each frame on top of the needle base
dspColor(COL_HUB, 0);
dspPos(PANEL_X + CX, PANEL_Y + CY);
dspFillCircle(HUB_R);
dspColor(COL_FACE, 0);
dspPos(PANEL_X + CX, PANEL_Y + CY);
dspCircle(HUB_R);
on_x = hx_out; on_y = hy_out;
}
// ── callbacks ──────────────────────────────────────────────────────
void main() {
addCommand("VM");
// Compute all derived radii/lengths from FACE_SIZE.
computeGeometry();
// Clear the whole panel to the background colour — this wipes whatever
// the previous program left on screen, and makes the area AROUND the
// gauge (outside PANEL_X..PANEL_X+FACE_SIZE) black.
dspColor(COL_BG, COL_BG);
dspPos(0, 0);
dspFillRect(1024, 1024); // oversize — panel clips it
// Allocate the canvas and paint the face into it — ONCE at startup.
face = imgCreate(FACE_SIZE, FACE_SIZE);
if (face < 0) {
addLog("voltmeter: imgCreate failed (PSRAM?)");
return;
}
// Black corners: canvas fills black, then the round bezel is drawn on
// top — anything outside R_BEZEL stays black.
imgClear(face, COL_BG);
imgBeginDraw(face);
drawFace(); // all dsp* calls target the canvas
imgEndDraw();
// Blit the finished face onto the panel as initial background
dspPushImageRect(face, 0, 0, PANEL_X, PANEL_Y, FACE_SIZE, FACE_SIZE);
// Initial needle
on_x = CX; on_y = CY;
drawNeedle();
prev_v = value;
}
void EverySecond() {
if (face < 0) { return; }
// Synthetic value walk for demo purposes — step is ±(range/10).
int stepmax = (VAL_MAX - VAL_MIN) / 10;
if (stepmax < 1) { stepmax = 1; }
int step = ((tasm_second * 13) % (stepmax * 2 + 1)) - stepmax;
value = value + step;
if (value < VAL_MIN) { value = VAL_MIN; }
if (value > VAL_MAX) { value = VAL_MAX; }
if (value == prev_v) { return; }
eraseNeedle(on_x, on_y, NEEDLE_W);
drawNeedle();
prev_v = value;
}
void Command(char cmd[]) {
char msg[64];
char arg[16];
// Tasmota uppercases the subcommand, so VMSET arrives as "SET n"
if (strFind(cmd, "SET") == 0) {
int v;
if (strlen(cmd) > 4) {
strSub(arg, cmd, 4, 0);
v = atoi(arg);
} else {
v = 0;
}
if (v < VAL_MIN) { v = VAL_MIN; }
if (v > VAL_MAX) { v = VAL_MAX; }
value = v;
if (face >= 0) {
eraseNeedle(on_x, on_y, NEEDLE_W);
drawNeedle();
prev_v = value;
}
sprintf(msg, "{\"VM\":{\"value\":%d}}", value);
responseCmnd(msg);
} else {
sprintf(msg, "{\"VM\":\"? VMSET <%d..%d>\"}", VAL_MIN, VAL_MAX);
responseCmnd(msg);
}
}