Multiple NTC on ATTiny85 with I²C interface

This compact ATTiny85 firmware continuously measures two NTC thermistors, maintains one hour of minute-by-minute history, computes both 60-minute and 10-minute temperature trends, and exposes all data in a 16-byte I²C response:

• Thermistor Conversion
– Every second, reads two ADC pins, converts raw counts → resistance → temperature (centi-°C) via the Steinhart-Hart β-model.
– Stores the latest temperatures in latest[0] and latest[1].

• Circular Buffer & Trend Calculation
– Once per minute, pushes the latest temperatures into a 60-entry circular buffer.
– Computes linear-regression slopes (“trends”) over the last 60 samples and the last 10 samples for each channel, updating trend60[] and trend10[].

• I²C Interface (Address 0x42)
– On every master request, returns exactly 16 bytes:
1. Two 16-bit latest readings (channel 0 & 1)
2. Two 16-bit 60-min trend slopes
3. Two 16-bit 10-min trend slopes
4. Four zero bytes padding

Slave code

The slave sketch uses a simple β-model to convert ADC readings into temperature. For each thermistor channel you must specify four parameters:

Rser (Ω)
The fixed series resistor value in the voltage divider with the NTC.
Example: 10 000 Ω when you place a 10 kΩ resistor in series with the thermistor.

Rnom (Ω)
The nominal resistance of the NTC at the reference temperature (Tnom).
Example: 10 000 Ω for a 10 kΩ thermistor specified at 25 °C.

Tnom (°C)
The reference temperature at which Rnom is measured.
Commonly 25 °C for many NTC datasheets.

β (K)
The “beta” coefficient from the NTC datasheet, defining how resistance changes with temperature.
Typical values range from ~3 000 K to ~4 500 K.

These four values go into an array of structs: { Rser, Rnom, Tnom, β }

At runtime, the code:

  1. Reads the ADC (0…1023) → computes the voltage‐divider ratio.
  2. Solves for the thermistor resistance:
    Rntc = Rser × (1/ratio – 1)
  3. Applies the β-model (Steinhart–Hart simplified):
    1/T = 1/(Tnom+273.15) + (1/β)·ln(Rntc/Rnom)
  4. Converts from kelvin (T) → °C → rounds to integer centi-°C.

By adjusting Rser, Rnom, Tnom, and β, you can calibrate the sketch for virtually any 2-wire NTC thermistor.

#include <Arduino.h>
#include <Wire.h>

#define SLAVE_ADDR   0x42
#define NTC_CHANS    2
#define MINUTES      60

const uint8_t ntcPin[NTC_CHANS] = { A2, A3 };
struct NTC {
  float Rser, Rnom, Tnom, beta;
};
NTC params[NTC_CHANS] = {
  {10000, 10000, 25, 3950},
  {10000, 10000, 25, 3950}
};

// Circular buffer and outputs
volatile int16_t buf[MINUTES][NTC_CHANS];
volatile int16_t latest[NTC_CHANS];
volatile int16_t trend60[NTC_CHANS];
volatile int16_t trend10[NTC_CHANS];

uint16_t sampleCount = 0;
uint8_t  writeIndex  = 0;

// Change here: accept a volatile reference
void computeTrend(uint8_t channel, uint8_t W, volatile int16_t &outTrend) {
  long sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
  for (uint8_t i = 0; i < W; i++) {
    int idx = writeIndex - 1 - i;
    if (idx < 0) idx += MINUTES;
    long x = i;
    long y = buf[idx][channel];
    sumX  += x;
    sumY  += y;
    sumXY += x * y;
    sumXX += x * x;
  }
  long N = W;
  long num = N * sumXY - sumX * sumY;
  long den = N * sumXX - sumX * sumX;
  outTrend = (den != 0) ? int16_t((float)num / den) : 0;
}

void setup() {
  analogReference(DEFAULT);
  Wire.begin(SLAVE_ADDR);
  Wire.onRequest([]() {
    // Build 16-byte response
    // [0–1] latest[0], [2–3] latest[1]
    // [4–5] trend60[0], [6–7] trend60[1]
    // [8–9] trend10[0],  [10–11] trend10[1]
    // [12–15] = 0
    for (uint8_t i = 0; i < NTC_CHANS; i++) {
      Wire.write(uint8_t(latest[i] >> 8));
      Wire.write(uint8_t(latest[i] & 0xFF));
    }
    for (uint8_t i = 0; i < NTC_CHANS; i++) {
      Wire.write(uint8_t(trend60[i] >> 8));
      Wire.write(uint8_t(trend60[i] & 0xFF));
    }
    for (uint8_t i = 0; i < NTC_CHANS; i++) {
      Wire.write(uint8_t(trend10[i] >> 8));
      Wire.write(uint8_t(trend10[i] & 0xFF));
    }
    for (uint8_t i = 12; i < 16; i++) Wire.write((uint8_t)0);
  });
}

void loop() {
  int16_t centi[NTC_CHANS];
  for (uint8_t c = 0; c < NTC_CHANS; c++) {
    uint16_t raw = analogRead(ntcPin[c]);
    if (raw < 1 || raw > 1022) {
      centi[c] = 0;
    } else {
      float ratio = raw / 1023.0f;
      float Rntc  = params[c].Rser * (1.0f / ratio - 1.0f);
      float invT  = log(Rntc / params[c].Rnom) / params[c].beta
                    + 1.0f / (params[c].Tnom + 273.15f);
      float kelv  = 1.0f / invT;
      centi[c] = int16_t(round((kelv - 273.15f) * 100.0f));
    }
    delay(5);
    latest[c] = centi[c];
  }

  sampleCount++;
  if (sampleCount >= MINUTES) {
    sampleCount = 0;
    buf[writeIndex][0] = latest[0];
    buf[writeIndex][1] = latest[1];
    writeIndex = (writeIndex + 1) % MINUTES;

    // Compute 60-min and 10-min trends for each channel
    for (uint8_t c = 0; c < NTC_CHANS; c++) {
      computeTrend(c, MINUTES, trend60[c]);
      computeTrend(c, 10,      trend10[c]);
    }
  }
  delay(1000);
}

Helper components (not required on the ATTiny85) include:
– An Arduino-based I²C master that polls every few seconds, unpacks the eight signed 16-bit values, converts to °C and °C/min, and streams JSON over Serial.
– A browser-based front end (HTML+JS) using the Web Serial API to read JSON lines, render a table, and draw a live SVG chart with optional point markers and smart X-axis label skipping.

Master code

#include <Wire.h>

#define SLAVE_ADDR  0x42

void setup() {
  Serial.begin(115200);
  Wire.begin();   // I2C master
  delay(100);
}

void loop() {
  // 1) Request 16 bytes from the slave
  Wire.requestFrom(SLAVE_ADDR, 16);
  if (Wire.available() < 16) {
    Serial.println(F("{\"error\":\"I2C read failed\"}"));
    delay(500);
    return;
  }

  // 2) Read eight 16-bit words
  int16_t raw[8];
  for (int i = 0; i < 8; i++) {
    uint8_t hi = Wire.read();
    uint8_t lo = Wire.read();
    raw[i]     = (int16_t)((hi << 8) | lo);
  }

  // 3) Convert from centi-°C → °C (floats)
  float ntc1     = raw[0] / 100.0f;
  float ntc2     = raw[1] / 100.0f;
  float trend60_1 = raw[2] / 100.0f;
  float trend60_2 = raw[3] / 100.0f;
  float trend10_1 = raw[4] / 100.0f;
  float trend10_2 = raw[5] / 100.0f;

  // 4) Print one JSON object per loop
  //    e.g. {"t":12345,"ntc1":23.45, ...}
  unsigned long t_ms = millis();
  Serial.print('{');
  Serial.print("\"t\":");        Serial.print(t_ms);
  Serial.print(",\"ntc1\":");   Serial.print(ntc1, 2);
  Serial.print(",\"ntc2\":");   Serial.print(ntc2, 2);
  Serial.print(",\"tr60_1\":"); Serial.print(trend60_1, 3);
  Serial.print(",\"tr60_2\":"); Serial.print(trend60_2, 3);
  Serial.print(",\"tr10_1\":"); Serial.print(trend10_1, 3);
  Serial.print(",\"tr10_2\":"); Serial.print(trend10_2, 3);
  Serial.println('}');

  delay(5000);
}

HTML + JS

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8"/>
  <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
  <title>Multiple NTC on ATTiny85</title>
  <style>
    /* RESET & BASE */
    * { box-sizing:border-box; margin:0; padding:0 }
    body {
      font-family:"Segoe UI",sans-serif;
      background:#f5f7fa; color:#333;
      max-width:960px; margin:2em auto; padding:0 1em;
    }
    h1 { margin-bottom:.5em }

    /* SECTION CARD */
    .section {
      background:#fff; border-radius:6px;
      box-shadow:0 1px 4px rgba(0,0,0,.1);
      margin-bottom:1em; overflow:hidden;
      position: relative;
    }

    /* mimic <th> styling */
    .table-header-mimic {
      position: sticky; top:0;
      background:#f5f5f5;
      border-bottom:1px solid #e0e0e0;
      padding:.5em .75em;
      display:flex; align-items:center; justify-content:space-between;
      z-index:5;
    }
    .table-header-mimic h2 {
      margin:0; font-size:.85em; font-weight:600;
    }

    /* BUTTONS & SWITCH */
    button {
      display:inline-flex; align-items:center; justify-content:center;
      padding:.25em .5em; margin:0 .3em;
      font-size:.9em; border:none; border-radius:4px;
      cursor:pointer; transition:background .2s;
      background:#0366d6; color:#fff;
      height:1.8em; line-height:1;
    }
    button:hover { background:#024f9b }
    label.switch {
      display:inline-flex; align-items:center; gap:.3em;
      font-size:.9em; user-select:none;
    }
    label.switch input { margin-right:.3em }

    /* TABLE */
    #table-container {
      max-height:200px; overflow-y:auto;
    }
    table {
      width:100%; border-collapse:collapse;
      font-size:.85em;
    }
    th, td {
      padding:.5em .75em; text-align:center;
      border-bottom:1px solid #e0e0e0;
    }
    th {
      position:sticky; top:0;
      background:#f5f5f5; z-index:3;
    }
    tr.highlight {
      background:rgba(255,235,59,.3);
      transition:background .3s;
    }

    /* CHART */
    #chart-container {
      width:100%; height:300px;
      background:#fafafa; border-radius:0 0 6px 6px;
      overflow:hidden;
    }
    svg {
      width:100%; height:100%;
    }
    /* grid, axes, ticks, labels, lines, trends */
    .grid-line { stroke:#ddd; stroke-width:1; }
    .axis      { stroke:#333; stroke-width:1.2; }
    .tick      { stroke:#666; stroke-width:1; }
    .label     { fill:#333; font-size:11px; text-anchor:middle; }
    .ylabel    { fill:#333; font-size:11px; text-anchor:start; }
    .line1     { fill:none; stroke:#e74c3c; stroke-width:2.5; }
    .line2     { fill:none; stroke:#3498db; stroke-width:2.5; }
    .trend1    { stroke:#c0392b; stroke-width:2; stroke-dasharray:4 3; fill:none; }
    .trend2    { stroke:#2980b9; stroke-width:2; stroke-dasharray:4 3; fill:none; }

    /* FOOTER */
    footer {
      text-align:center; padding:1em 0;
      font-size:.85em; color:#666;
    }
    footer a {
      color:#0366d6; text-decoration:none;
    }
    footer a:hover {
      text-decoration:underline;
    }
  </style>
</head>
<body>
  <h1>Multiple NTC on ATTiny85</h1>

  <!-- CONTROLS -->
  <div class="section">
    <div class="table-header-mimic">
      <h2>Controls</h2>
      <div>
        <button id="connectBtn">Connect</button>
        <button id="exportBtn">Export CSV</button>
        <label class="switch">
          <input type="checkbox" id="toggleDots" checked>
          Show Dots
        </label>
      </div>
    </div>
  </div>

  <!-- DATA TABLE -->
  <div class="section">
    <div class="table-header-mimic">
      <h2>Data Table</h2>
    </div>
    <div id="table-container">
      <table id="data-table">
        <thead>
          <tr>
            <th>Time</th><th>NTC1 °C</th><th>NTC2 °C</th>
            <th>Trend60 1</th><th>Trend60 2</th>
            <th>Trend10 1</th><th>Trend10 2</th>
          </tr>
        </thead>
        <tbody></tbody>
      </table>
    </div>
  </div>

  <!-- LIVE CHART -->
  <div class="section">
    <div class="table-header-mimic">
      <h2>Live Chart</h2>
      <div></div>
    </div>
    <div id="chart-container">
      <svg id="chart-svg"></svg>
    </div>
  </div>

  <footer>
    © 2024 <a href="https://www.ctvrtky.info" target="_blank">www.ctvrtky.info</a>
  </footer>

  <script>
    // ─────────────────────────────────────────────────────────
    // ELEMENTS & STATE
    const btnConnect = document.getElementById('connectBtn');
    const btnExport  = document.getElementById('exportBtn');
    const chkDots    = document.getElementById('toggleDots');
    const tbody      = document.querySelector('#data-table tbody');
    const svg        = document.getElementById('chart-svg');

    let port, reader, buffer = '';
    const rows = [];
    const maxRows = 100, maxPts = 50;
    let showDots = true;

    chkDots.addEventListener('change', ()=>{ showDots=chkDots.checked; drawChart(); });
    btnExport.addEventListener('click', exportCsv);
    btnConnect.addEventListener('click', connectSerial);

    // ─────────────────────────────────────────────────────────
    async function connectSerial(){
      port = await navigator.serial.requestPort();
      await port.open({ baudRate: 115200 });
      readLoop();
    }

    async function readLoop(){
      const dec = new TextDecoderStream();
      port.readable.pipeTo(dec.writable);
      reader = dec.readable.getReader();
      while(true){
        const {value, done} = await reader.read();
        if(done) break;
        buffer += value;
        let idx;
        while((idx = buffer.indexOf('\n')) >= 0){
          const line = buffer.slice(0, idx).trim();
          buffer = buffer.slice(idx + 1);
          if(line) handleLine(line);
        }
      }
    }

    function handleLine(line){
      let obj;
      try { obj = JSON.parse(line); } catch { return; }
      if(obj.error) return;
      rows.push(obj);
      if(rows.length > maxRows) rows.shift();
      updateTable();
      drawChart();
    }

    // ─────────────────────────────────────────────────────────
    function updateTable(){
      tbody.innerHTML = '';
      rows.forEach(d=>{
        const tr = document.createElement('tr');
        tr.innerHTML = `
          <td>${msToHMS(d.t)}</td>
          <td>${d.ntc1.toFixed(2)}</td>
          <td>${d.ntc2.toFixed(2)}</td>
          <td>${d.tr60_1.toFixed(3)}</td>
          <td>${d.tr60_2.toFixed(3)}</td>
          <td>${d.tr10_1.toFixed(3)}</td>
          <td>${d.tr10_2.toFixed(3)}</td>
        `;
        tbody.appendChild(tr);
      });
      // highlight last row & scroll
      const all = tbody.querySelectorAll('tr');
      all.forEach(r=>r.classList.remove('highlight'));
      if(all.length){
        const last = all[all.length -1];
        last.classList.add('highlight');
        const c = document.getElementById('table-container');
        c.scrollTop = c.scrollHeight - c.clientHeight;
      }
    }

    // ─────────────────────────────────────────────────────────
    function exportCsv(){
      if(!rows.length){ alert('No data'); return; }
      const hdr = ['Time','NTC1','NTC2','Trend60_1','Trend60_2','Trend10_1','Trend10_2'];
      const lines = rows.map(r=>[
        msToHMS(r.t),
        r.ntc1.toFixed(2),
        r.ntc2.toFixed(2),
        r.tr60_1.toFixed(3),
        r.tr60_2.toFixed(3),
        r.tr10_1.toFixed(3),
        r.tr10_2.toFixed(3)
      ].join(','));
      const csv = [hdr.join(',')].concat(lines).join('\r\n');
      const blob = new Blob([csv],{type:'text/csv'});
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = `data_${new Date().toISOString().replace(/[:.]/g,'-')}.csv`;
      a.click();
      URL.revokeObjectURL(url);
    }

    // ─────────────────────────────────────────────────────────
    function drawChart(){
      while(svg.firstChild) svg.removeChild(svg.firstChild);
      if(!rows.length) return;

      // dims & margins
      const W = svg.clientWidth, H = svg.clientHeight;
      const ctx = document.createElement('canvas').getContext('2d');
      ctx.font = '11px "Segoe UI"';
      const tickL = 6, pad = 10;
      const labelW = ctx.measureText('-99.9').width;
      const m = { left: labelW+tickL+pad, right:40, top:20, bottom:40 };
      const w = W - m.left - m.right, h = H - m.top - m.bottom;

      // data slice
      const pts = rows.slice(-maxPts);
      const xs  = pts.map(r=>r.t);
      const y1  = pts.map(r=>r.ntc1);
      const y2  = pts.map(r=>r.ntc2);

      // domains
      let minX = Math.min(...xs), maxX = Math.max(...xs);
      if(minX===maxX) maxX = minX + 1000;
      const minY = Math.min(...y1, ...y2);
      const maxY = Math.max(...y1, ...y2);
      const padY = (maxY-minY)*0.1 || 1;
      const x0 = minX, x1 = maxX;
      const y0 = minY - padY, y1b = maxY + padY;

      // scales
      const mapX = x=>m.left + ((x-x0)/(x1-x0))*w;
      const mapY = y=>m.top + h - ((y-y0)/(y1b-y0))*h;

      // grid horizontal
      const yTicks = 5;
      for(let i=0; i<=yTicks; i++){
        const v = y0 + (i/yTicks)*(y1b-y0);
        const Yp = mapY(v);
        mkLine(m.left, Yp, m.left+w, Yp, 'grid-line');
      }
      // grid vertical
      xs.forEach(t=>{
        const Xp = mapX(t);
        mkLine(Xp, m.top, Xp, m.top+h, 'grid-line');
      });

      // axes
      mkLine(m.left, m.top, m.left, m.top+h, 'axis');
      mkLine(m.left, m.top+h, m.left+w, m.top+h, 'axis');

      // X ticks & labels (skipping)
      const maxLabels = 10;
      const total = xs.length;
      const skip  = Math.ceil(total / maxLabels);

      xs.forEach((t,i)=>{
        const Xp = mapX(t);
        mkLine(Xp, m.top+h, Xp, m.top+h+tickL, 'tick');
        if(i % skip === 0 || i===total-1){
          mkText(Xp, m.top+h+pad+11, msToHMS(t), 'label');
        }
      });

      // Y ticks & labels
      for(let i=0; i<=yTicks; i++){
        const v = y0 + (i/yTicks)*(y1b-y0);
        const Yp = mapY(v);
        mkLine(m.left-tickL, Yp, m.left, Yp, 'tick');
        mkText(pad, Yp+4, v.toFixed(1), 'ylabel');
      }

      // optional dots
      if(showDots){
        xs.forEach((t,j)=>{
          mkCircle(mapX(t), mapY(y1[j]), 3, 'line1');
          mkCircle(mapX(t), mapY(y2[j]), 3, 'line2');
        });
      }

      // trend lines
      const lr1 = linearRegression(xs, y1);
      const lr2 = linearRegression(xs, y2);
      const Xs = xs[0], Xe = xs[xs.length-1];
      const Ys1 = lr1.m*Xs + lr1.b, Ye1 = lr1.m*Xe + lr1.b;
      const Ys2 = lr2.m*Xs + lr2.b, Ye2 = lr2.m*Xe + lr2.b;
      mkLine(mapX(Xs), mapY(Ys1), mapX(Xe), mapY(Ye1), 'trend1');
      mkLine(mapX(Xs), mapY(Ys2), mapX(Xe), mapY(Ye2), 'trend2');

      // Bézier curves
      function build(arr){
        let d = '';
        arr.forEach((_,i)=>{
          const X = mapX(xs[i]), Y = mapY(arr[i]);
          if(i===0) d=`M${X},${Y}`;
          else {
            const X0=mapX(xs[i-1]), Y0=mapY(arr[i-1]);
            const XC=(X0+X)/2, YC=(Y0+Y)/2;
            d+=` Q${X0},${Y0} ${XC},${YC}`;
            if(i===arr.length-1) d+=` T${X},${Y}`;
          }
        });
        return d;
      }
      mkPath(build(y1), 'line1');
      mkPath(build(y2), 'line2');
    }

    // ─────────────────────────────────────────────────────────
    // HELPERS: SVG & MATH
    function mkLine(x1,y1,x2,y2,cls){
      const l = document.createElementNS('http://www.w3.org/2000/svg','line');
      l.setAttribute('x1',x1); l.setAttribute('y1',y1);
      l.setAttribute('x2',x2); l.setAttribute('y2',y2);
      l.setAttribute('class',cls);
      svg.appendChild(l);
    }
    function mkText(x,y,text,cls){
      const t = document.createElementNS('http://www.w3.org/2000/svg','text');
      t.setAttribute('x',x); t.setAttribute('y',y);
      t.setAttribute('class',cls);
      t.textContent = text;
      svg.appendChild(t);
    }
    function mkCircle(cx,cy,r,cls){
      const c = document.createElementNS('http://www.w3.org/2000/svg','circle');
      c.setAttribute('cx',cx); c.setAttribute('cy',cy);
      c.setAttribute('r',r);  c.setAttribute('class',cls);
      svg.appendChild(c);
    }
    function mkPath(d,cls){
      const p = document.createElementNS('http://www.w3.org/2000/svg','path');
      p.setAttribute('d',d); p.setAttribute('class',cls);
      svg.appendChild(p);
    }
    function linearRegression(xs, ys){
      const n=xs.length;
      let sx=0, sy=0, sxy=0, sxx=0;
      for(let i=0;i<n;i++){
        sx+=xs[i]; sy+=ys[i];
        sxy+=xs[i]*ys[i]; sxx+=xs[i]*xs[i];
      }
      const m = (n*sxy - sx*sy)/(n*sxx - sx*sx);
      const b = (sy - m*sx)/n;
      return {m,b};
    }
    function msToHMS(ms){
      const t = Math.max(0, Math.floor(ms/1000));
      const s = t%60, m = Math.floor(t/60)%60, h = Math.floor(t/3600);
      const pad = n => String(n).padStart(2,'0');
      return `${pad(h)}:${pad(m)}:${pad(s)}`;
    }

    // ensure chart resizes
    window.addEventListener('resize', drawChart);
  </script>
</body>
</html>

Leave a Reply