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:
- Reads the ADC (0…1023) → computes the voltage‐divider ratio.
- Solves for the thermistor resistance:
Rntc = Rser × (1/ratio – 1) - Applies the β-model (Steinhart–Hart simplified):
1/T = 1/(Tnom+273.15) + (1/β)·ln(Rntc/Rnom) - 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>