Skip to content

voltmeter.tc

voltmeter.tc — procedurally-drawn analog voltmeter with dirty-rect needle

Source on GitHub

// 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);
    }
}