Controlling multiple heatbands using PWM via an Atmel AVR

07 May 2022 - tsp
Last update 07 May 2022
Reading time 11 mins

What’s this project about

So we had to bake our vacuum system again and now are at the stage of pumping down and baking the system again. Since I always wanted to automate that process to make it less error prone and also remote controllable I decided to take the chance and build a simple PWM regulator. This project will be done in two steps due to tight timing constraints. First I simply implemented an PWM controller inside an AVR that’s remote controllable via an ESP8266 over a simple web interface. The network connectivity will be handled via wireless LAN which is somewhat sub optimal compared to wired Ethernet though. This will be used during the baking of our first chamber.

The long term goal would be to implement a PID regulator that gets a fed of the already attached thermocouples to actively control the heating and cooling slope, perform monitoring and prevent the buildup of temperature gradients that might kill various components (mainly windows which tend to break with too large temperature gradients due to low heat conductivity and high stress) - and to provide a way to perform the bakeout without much human intervention to waste less work-hours and thus run way more economically.

This also sums up the stages of the project and it’s parts:

The latter stages are currently not implemented but planned at sometime in the future if required:

At first this looks like a hack but it will provide way better and more safe control than previously since this had been done using autotransformers and manual control - and in the beginning even manual thermocouple readout. Since baking often takes weeks at high temperatures (low temperature bakeouts up to 150 degrees Celsius that mainly help to remove water vapor and trapped Hydrogen from the stainless steel walls, high temperature bakeouts up to 400 degrees Celsius that are also capable of removing contamination by many common hydrocarbons) and since one often has problematic stuff around vacuum chambers more monitoring and safety features that can be built around the control facilities are a big gain. And it will reduce the possibility for human error. Note that of course a simple regulator without feedback and monitoring does not offer any gain - as usual safety and reasonable gain is only achieved in combination with monitoring and control loops.

Note: The control loop hasn’t been implemented until now since the heatbands worked pretty stable.

Solid state relays, heatbands and timing considerations

The solid state relays that are going to be used are simple triac based 25A solid state relays that can be controlled by any DC voltage between 3 and 32V. The relays themselves include an optotriac, an indicator LED as well as a current limiting resistor - they have an enable current of about 7.5 to 20 mA and thus can be directly attached to an AVR microprocessor. They’re capable of controlling up to 25A of current (depends on the model) at 24 to 380 V AC - note these SSRs are not capable of controlling DC since the Triacs won’t stop conducting without a zero crossing.

Note that some of the cheaper SSRs of similar kind should not really be trusted with their rating according to some hobbyist sources - just do proper verification and take care of proper thermal connection to some heat sink - and read the datasheets.

The initial setup has been built on an optics breadboard to safe time and get into a usable state as fast as possible:

Whole setup

The heatbands that are used are silicone wrapped heatbands with 100W power that operate at up to 200V (i.e. a maximum of 500 mA of current). They’re similar to those 80W heatbands linked here.

To perform PWM using Triacs one should use a minimum time slice larger than a single oscillation. At 50 Hz line frequency these are 20ms. Since I wanted to support 1000 subdivisions I still decided to set a single PWM tick to 4ms - below a single oscillation. This of course means that any setting will be unstable up to this granularity. So a single full cycle will be 4 seconds. To prevent too large load transitions I also implemented staggered enabling of the bands - usually they’re started at the beginning of each PWM cycle. In my implementation they’re started shifted by $\frac{2 s}{n_{bands}}$, for 6 bands this means they’re enabled around $300 ms$ apart.


The whole prototype control system is built out of just prototyping boards (at least in the beginning):

Electronics setup

The AVR firmware

The AVR firmware consists of two parts:

The PWM controller is the workhorse of the whole system. It’s realized by a simple counter array - since the frequency that’s required is pretty low compared to the resolution offered by the AVRs timers and since some clock skew is really unimportant for this problem I just used my clock module that supplies a millis() function. This allows to keep track when the last PWM pulse has been triggered in a synchronous fashion. Whenever at least as much time as a single PWM pulse has been elapsed the counters for all channels are increased. If the counter value is above the configured duty cycle the channel gets turned off, else it gets turned on. Whenever the counters reach 1024 they overflow back to 0. To implement staggered startup a fixed offset value is written into the counter registers during initialization:

#include "./pwmout.h"
#include "./sysclk.h"

#ifdef __cplusplus
	extern "C" {

struct pwmOutputState pwmState[PWMCHANNELS_MAX];
unsigned long int dwPWMLastTick;
unsigned long int dwPWMTickDuration;
unsigned long int dwConfiguredChannelCount;

unsigned long int pwmGetConfiguredChannelCount() {
  return dwConfiguredChannelCount;

void pwmInit(
  unsigned long int dwTickLength,
  bool bStaggeredFirstTicks,

  unsigned long int dwChannels,
  struct pwmConfiguration* lpPortConfiguration
) {
  unsigned long int i;
  unsigned long int dwOffsetStepSize;

  for(i = 0; i < PWMCHANNELS_MAX; i=i+1) {
    pwmState[i].pwmCycle   = 0;
    pwmState[i].avrPort    = NULL;
    pwmState[i].avrPin     = 0x00;
    pwmState[i].pwmOffset  = 0;
    pwmState[i].pwmCounter = 0;

  if(bStaggeredFirstTicks != true) {
    dwOffsetStepSize = 0;
  } else {
    dwOffsetStepSize = (dwTickLength * 10000L) / dwChannels;

  for(i = 0; i < dwChannels; i=i+1) {
    pwmState[i].pwmOffset = i * dwOffsetStepSize;
    pwmState[i].avrPort = lpPortConfiguration[i].avrPort;
    pwmState[i].avrPin = lpPortConfiguration[i].avrPin;

    lpPortConfiguration[i].avrPortDDR[0] = lpPortConfiguration[i].avrPortDDR[0] | lpPortConfiguration[i].avrPin;
    lpPortConfiguration[i].avrPort[0] = lpPortConfiguration[i].avrPort[0] & (~(lpPortConfiguration[i].avrPin));

  dwPWMLastTick = millis();
  dwPWMTickDuration = dwTickLength;

  dwConfiguredChannelCount = dwChannels;

void pwmTickLoop() {
  unsigned long int dwCurrentMillis = millis();
  unsigned long int dwElapsedTime;
  unsigned long int i;

  if(dwCurrentMillis > dwPWMLastTick) {
    dwElapsedTime = dwCurrentMillis - dwPWMLastTick;
  } else {
    dwElapsedTime = ((~0) - dwPWMLastTick) + dwCurrentMillis;

  if(dwElapsedTime < dwPWMTickDuration) { return; }

  dwPWMLastTick = dwCurrentMillis;

  for(i = 0; i < PWMCHANNELS_MAX; i=i+1) {
    if(pwmState[i].avrPort != NULL) {
      pwmState[i].pwmCounter = (pwmState[i].pwmCounter  + 1) % 1000;

      if(((pwmState[i].pwmCounter  + pwmState[i].pwmOffset) % 1000) < pwmState[i].pwmCycle) {
        /* On */
        pwmState[i].avrPort[0] = pwmState[i].avrPort[0] | pwmState[i].avrPin;
      } else {
        /* Off */
        pwmState[i].avrPort[0] = pwmState[i].avrPort[0] & (~(pwmState[i].avrPin));

void pwmSet(
  unsigned long int dwChannel,
  unsigned long int dwPermille
) {
  if(dwChannel >= PWMCHANNELS_MAX) {

  if(dwPermille > 1000) { dwPermille = 1000; } /* Clamp in case we're larger than 1 ppm */
  pwmState[dwChannel].pwmCycle = dwPermille;

#ifdef __cplusplus
  } /* extern "C" { */

Serial communication is built around two ringbuffers that buffer data that should be transmitted or received. All messages use a binary synchronization pattern, provide a checksum and a termination symbol at the end of the message. This allows to detect transmission errors and resynchronize the message bus. In case of errors messages have to be dropped - so one would have to resend them in case one gets no confirmation.

Data bytes are read into a ringbuffer asynchronously by an ISR - and the transmitting ISR reads them from the transmit ringbuffer as long as data is available. The message processing itself is done from the main program loop in a synchronous fashion. This introduces of course some skew into the also synchronous PWM output but since processing packages is happening in the milliseconds range and not happening very often this has totally neglectable influence on the temperature profile.

The NodeMCU firmware

The NodeMCU basically only offers a webpage, MQTT connectivity and implements the same serial protocol as the AVR. The first revision is based on the Arduino libraries for the ESP8266 and is really dumb and simple (mainly due to time constraints) - and only supports a fixed number of output channels on the AVR. Later revisions should first query the number of output channels from the attached microcontroller so one can swap the WiFi or display units arbitrarily. It should also read the current state out of the controller on boot so one is able to reboot the ESP8266 without interrupting operation.

The controller has a pretty classic design:

In case no valid WiFi is configured or the configuration has been reset the controller spawns it’s own wireless network. This allows one to connect using a computer or a smartphone and provide basic configuration. In this case the ESP8266 provides a DHCP server and is reachable via via plain HTTP.

Whenever settings are updated the changes are written into the EEPROM and a reset is executed. This has no effect on the current running PWM cycles - but one currently looses the currently set PWM count (this will be fixed in a later version by querying the AVR about it’s state on boot and then implement a CQRS like pattern in which the reported cycles are always the ones queried from the AVR).

In it’s main loop the ESP8266:

The code

The whole code is available as a GitHub repository.

This article is tagged: Programming, Physics, Vacuum, AVR, Microcontroller, ANSI C, ESP8266

Data protection policy

Dipl.-Ing. Thomas Spielauer, Wien (

This webpage is also available via TOR at http://rh6v563nt2dnxd5h2vhhqkudmyvjaevgiv77c62xflas52d5omtkxuid.onion/

Valid HTML 4.01 Strict Powered by FreeBSD IPv6 support