John Bradnam
Published © GPL3+

Digital Protractor

10 Magnets and 2 Hall effect sensors form a angle encoder which is used to measure angles that are displayed on a OLED screen.

IntermediateFull instructions provided10 hours763
Digital Protractor

Things used in this project

Hardware components

Microchip ATtiny1614 Microprocessor
×1
OLED Display 128x32 0.91 inches with I2C Interface
DIYables OLED Display 128x32 0.91 inches with I2C Interface
×1
Neodymium Magnet 6mm x 3mm
×10
OA49E Hall Effect Sensor
×2
Tactile Switch, Top Actuated
Tactile Switch, Top Actuated
6mm shaft + button top
×1
Battery, 3.7 V
Battery, 3.7 V
120mA/hr with right angle 2-pin socket
×1

Software apps and online services

Arduino IDE
Arduino IDE

Hand tools and fabrication machines

3D Printer (generic)
3D Printer (generic)
Soldering iron (generic)
Soldering iron (generic)

Story

Read more

Custom parts and enclosures

STL Files

Files for 3D printing

Schematics

Schematic

PCB

Eagle files

Schematic & PCB in Eagle format

Code

Neodymium_Angle_Encoder_V2.ino

Arduino
/**************************************************************************
  Neodymium Angle Encoder

  Code by lingib - https://www.instructables.com/Neodymium-Angle-Encoder/
 
  Schematic & PCB at https://www.hackster.io/john-bradnam/electronic-scales-c97f1d
 
  2023-06-23 John Bradnam (jbrad2089@gmail.com)
    Create program for ATtiny1614

  The code for counting the sinewaves is explained here. 
  https://curiousscientist.tech/blog/as5600-magnetic-encoder-a-practical-example

  ------------
  About
  ------------
  This software calculates the angular position of a shaft.
  The hardware comprises 2 Hall effect transistors and 10 neodymium magnets.

  ----------
  COPYRIGHT
  ----------
  This code is free software: you can redistribute it and/or
  modify it under the terms of the GNU General Public License as published
  by the Free Software Foundation, either version 3 of the License, or
  (at your option) any later version.

  This software is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License. If
  not, see <http://www.gnu.org/licenses></http:>.

 --------------------------------------------------------------------------
 Arduino IDE:
 --------------------------------------------------------------------------
  BOARD: ATtiny1614/1604/814/804/414/404/214/204
  Chip: ATtiny1614
  Clock Speed: 20MHz
  millis()/micros(): "Enabled (default timer)"
  Programmer: jtag2updi (megaTinyCore)

  ATTiny1614 Pins mapped to Ardunio Pins
 
              +--------+
          VCC + 1   14 + GND
  (SS)  0 PA4 + 2   13 + PA3 10 (SCK)
        1 PA5 + 3   12 + PA2 9  (MISO)
  (DAC) 2 PA6 + 4   11 + PA1 8  (MOSI)
        3 PA7 + 5   10 + PA0 11 (UPDI)
  (RXD) 4 PB3 + 6    9 + PB0 7  (SCL)
  (TXD) 5 PB2 + 7    8 + PB1 6  (SDA)
              +--------+
  
 **************************************************************************/

#include <U8g2lib.h>
#include <EEPROM.h>
#include <avr/sleep.h>

#define SHUTDOWN_TIME 15000  //Timeout before sleep

#define TXD_PIN 5     //PB2
#define RXD_PIN 4     //PB3
#define SCA_PIN 6     //PB1
#define SCL_PIN 7     //PB0
#define SIN_PIN 10    //PA3
#define COS_PIN 0     //PA4
#define SWITCH_PIN 1  //PA5

U8G2_SSD1306_128X32_UNIVISION_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);

#define LONG_PRESS_TIME 3000
#define MEASURE_INTERVAL_TIME 500
unsigned long measureTimeout;
unsigned long sleepTimeout;

// ----- Shaft Angle Calculations
/*
   10 magnets produce five sinewaves per shaft revolution
   Each sinewave therefore equates to 72 degrees shaft rotation
*/
#define EPLISON_ANGLE 1.0                               // Amount of change in angle before 

float RawAngle;                                         // 360 degrees represents 72 degrees shaft rotation

int QuadrantNumber = 0;                                 // quadrant IDs
int PreviousQuadrantNumber = 0;                         // these are used for tracking the NumberOfTurns

int   N = 0;                                            // number of completed sinewaves
float StartAngle = 0;                                   // starting angle
float TaredAngle = 0;                                   // tared angle - based on the startup value
double ShaftAngle = 0;                                  // total absolute angular displacement
double LastShaftAngle = 0;                              // total absolute angular displacement

//EEPROM handling
//Uncomment next line to clear out EEPROM and reset
//#define RESET_EEPROM
#define EEPROM_ADDRESS 0
#define EEPROM_MAGIC 0x0BAD0DAD
typedef struct {
  uint32_t magic;
  bool calibrated;
  float
  A,                                                      // Sine wave
  Amax,
  Amin,
  Amid,
  Apeak;
  
  float
  B,                                                      // Cosine wave
  Bmax,
  Bmin,
  Bmid,
  Bpeak,
  Bnormal;
} EEPROM_DATA;

EEPROM_DATA EepromData;      //Current EEPROM settings

char line1[32]; 
char line2[32]; 

//-------------------------------------------------------------------------
// Initialise Hardware
void setup(void)
{
  pinMode(SIN_PIN, INPUT);
  pinMode(COS_PIN, INPUT);
  pinMode(SWITCH_PIN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(SWITCH_PIN),switchInterrupt,CHANGE);

  u8g2.begin();
  showSplashScreen();

  readEepromData();                                       //Get calibration values
  

  // ----- Record start angle
  /*
     Sensor arms to be at zero degrees at startup
  */
  StartAngle = raw_angle();                               // Get raw angle

  goToSleep();  
}

//--------------------------------------------------------------------
// Handle pin change interrupt when SWITCH is pressed
void switchInterrupt()
{
}

//--------------------------------------------------------------------
// Main program loop
void loop(void)
{
  if (millis() < sleepTimeout)
  {
    if (!EepromData.calibrated)
    {
      calibrate();  //start calibration procedure
    }
    
    RawAngle = raw_angle();
    TaredAngle = tare(RawAngle);
    N = numberOfSinewaves();                                 // Counts number of completed cycles N                                        //
    ShaftAngle = N * 72 + TaredAngle / 5;
  
    // ----- Display shaft angle
    if (millis() >= measureTimeout && LastShaftAngle != ShaftAngle) 
    {
      if (abs(LastShaftAngle - ShaftAngle) >= EPLISON_ANGLE)
      {
        sleepTimeout = millis() + SHUTDOWN_TIME;              // Restart timeout
      }
      line1[0] = '\0';
      line2[0] = '\0';
      strcpy(line1, "Shaft Angle");
      int t = (int)abs(round(ShaftAngle * 10));
      sprintf(line2,"%d.%d",t/10,t%10);
      displayLines();
      LastShaftAngle = ShaftAngle;
      measureTimeout = millis() + MEASURE_INTERVAL_TIME;    // Next update
    }
    
    long downTime = testButtonPress();
    if (downTime > 0 && downTime < LONG_PRESS_TIME)
    {
      goToSleep();
    }
    else if (downTime >= LONG_PRESS_TIME)
    {
      calibrate(); //start calibration procedure
    }
  }
  else
  {
    goToSleep();
  }
}

//--------------------------------------------------------------------
// Show splash screen
//--------------------------------------------------------------------

void showSplashScreen()
{
  //Splash screen 1
  displayLines("ATtiny1614", "Protractor");
  delay(5000);
}

//--------------------------------------------------------------------
// Display line buffers

void displayLines()
{
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_helvB12_tr); // helvetica bold
  int width = u8g2.getStrWidth(line1);
  u8g2.drawStr((128-width)>>1,14,line1);
  width = u8g2.getStrWidth(line2);
  u8g2.drawStr((128-width)>>1,29,line2);
  u8g2.sendBuffer();
}

//--------------------------------------------------------------------
// Display line buffers

void displayLines(char const* line1, char const* line2)
{
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_helvB12_tr); // helvetica bold
  int width = u8g2.getStrWidth(line1);
  u8g2.drawStr((128-width)>>1,14,line1);
  width = u8g2.getStrWidth(line2);
  u8g2.drawStr((128-width)>>1,29,line2);
  u8g2.sendBuffer();
}

//--------------------------------------------------------------------
// Measures the raw angle within any 72 degree segment of shaft rotation
//  Returns angle between 0 and 360 degrees
//--------------------------------------------------------------------

float raw_angle() 
{
  // ----- Locals
  float adc0;
  float adc1;

  // ----- Read A & B values
  adc0 = (float)analogRead(SIN_PIN);
  adc1 = (float)analogRead(COS_PIN);

  //Serial.print(adc0); Serial.print("\t"); Serial.println(adc1);   // Debug

  // ----- Measure the sine and cosine values
  EepromData.A = adc0 - EepromData.Amid;
  EepromData.B = (adc1 - EepromData.Bmid) * EepromData.Bnormal;

  // ----- Calculate the raw angle
  float angle = atan2(EepromData.B, EepromData.A) * RAD_TO_DEG;
  if (angle < 0)
  {
    angle += 360;                            // Needed as atan2 outputs negative values between 180~360 degrees
  }
  return angle;
}

//--------------------------------------------------------------------
// Get the angle that represents zero degrees
//--------------------------------------------------------------------

float tare(float angle)
{
  // ----- recalculate tared angle
  float taredAngle = angle - StartAngle;                // This tares the position

  if (taredAngle < 0)                                   // If the calculated angle is negative, we need to "normalize" it
  {
    taredAngle = taredAngle + 360;                      // correction for negative numbers (i.e. -15 becomes +345)
  }

  return taredAngle;
}

//--------------------------------------------------------------------
// The code for counting the sinewaves is explained here.
// https://curiousscientist.tech/blog/as5600-magnetic-encoder-a-practical-example
//--------------------------------------------------------------------

int numberOfSinewaves()
{
  /*
    //Quadrants:
    4  |  1
    ---|---
    3  |  2
  */

  // ----- Locals
  static int sinewaves = 0;

  // ----- Quadrant 1
  if (TaredAngle >= 0 && TaredAngle <= 90) 
  {
    QuadrantNumber = 1;
  }

  //Quadrant 2
  if (TaredAngle > 90 && TaredAngle <= 180) 
  {
    QuadrantNumber = 2;
  }

  //Quadrant 3
  if (TaredAngle > 180 && TaredAngle <= 270) 
  {
    QuadrantNumber = 3;
  }

  // ----- Quadrant 4
  if (TaredAngle > 270 && TaredAngle < 360) 
  {
    QuadrantNumber = 4;
  }

  if (QuadrantNumber != PreviousQuadrantNumber)                   // Have we changed quadrant
  {
    if (QuadrantNumber == 1 && PreviousQuadrantNumber == 4) 
    {
      sinewaves++;                                                // 4 --> 1 transition: CW rotation
    }

    if (QuadrantNumber == 4 && PreviousQuadrantNumber == 1) 
    {
      sinewaves--;                                                // 1 --> 4 transition: CCW rotation
    }

    PreviousQuadrantNumber = QuadrantNumber;                      // Update to the current quadrant
  }

  return sinewaves;
}

//--------------------------------------------------------------------
// Instructions:
//  - Press button for longer than LONG_PRESS_TIME to calibrate.
//  - Rotate the magnet arm until the display readings remain constant.
//  - Press button.
//  - Readings stored in EEPROM.
//--------------------------------------------------------------------

void calibrate() 
{
  // ----- Initialise max/min values
  EepromData.Amax = -10000.0;
  EepromData.Amin = 10000.0;
  EepromData.Bmax = -10000.0;
  EepromData.Bmin = 10000.0;

  strcpy(line1, "Calibration");
  strcpy(line2, "Rotate arm");
  displayLines();

  // ----- Find max/min values
  while (digitalRead(SWITCH_PIN) == HIGH) 
  {

    // ----- Read sine and cosine values
    EepromData.A = (float)analogRead(SIN_PIN);
    EepromData.B = (float)analogRead(COS_PIN);

    // ----- Record Apeak & Amid values
    EepromData.Amax = max(EepromData.Amax, EepromData.A);
    EepromData.Amin = min(EepromData.Amin, EepromData.A);
    EepromData.Bmax = max(EepromData.Bmax, EepromData.B);
    EepromData.Bmin = min(EepromData.Bmin, EepromData.B);

    // ----- Calculate scaling factors
    EepromData.Amid = (EepromData.Amax + EepromData.Amin) / 2;
    EepromData.Apeak = EepromData.Amax - EepromData.Amid;
    EepromData.Bmid = (EepromData.Bmax + EepromData.Bmin) / 2;
    EepromData.Bpeak = EepromData.Bmax - EepromData.Bmid;
    EepromData.Bnormal = EepromData.Apeak / EepromData.Bpeak;

    int t = (int)round(EepromData.Bnormal * 10);
    sprintf(line2,"%d.%d",t/10,t%10);
    displayLines();
    
    delay(100);
  }
  // Copy these values to EEPROM
  EepromData.calibrated = true;
  writeEepromData();
  
  // Wait for release
  while (digitalRead(SWITCH_PIN) == LOW);

  // Reset auto shutdown timer
  measureTimeout = millis();
  sleepTimeout = millis() + SHUTDOWN_TIME;

  // Get zero degree angle
  StartAngle = raw_angle();                               // Get raw angle
}

//--------------------------------------------------------------------
// Wait for button to be pressed

void waitForButtonPress()
{
  bool pressed = false;
  while (!pressed)
  {
    if (digitalRead(SWITCH_PIN) == LOW)
    {
      delay(10);  //Debounce
      if (digitalRead(SWITCH_PIN) == LOW)
      {
        //wait until release
        while (digitalRead(SWITCH_PIN) == LOW);
        pressed = true;
      }
    }
  }
}

//--------------------------------------------------------------------
// Test if button has been pressed
//  returns 0 - not pressed or time button has been pressed in mS

long testButtonPress()
{
  if (digitalRead(SWITCH_PIN) == LOW)
  {
    delay(10);  //Debounce
    if (digitalRead(SWITCH_PIN) == LOW)
    {
      long startTime = millis();
      //wait until release
      while (digitalRead(SWITCH_PIN) == LOW);
      return (millis() - startTime);
    }
  }
  return 0;
}

//--------------------------------------------------------------------
//Write the EepromData structure to EEPROM
void writeEepromData(void)
{
  //This function uses EEPROM.update() to perform the write, so does not rewrites the value if it didn't change.
  EEPROM.put(EEPROM_ADDRESS,EepromData);
}

//--------------------------------------------------------------------
//Read the EepromData structure from EEPROM, initialise if necessary
void readEepromData(void)
{
#ifndef RESET_EEPROM
  EEPROM.get(EEPROM_ADDRESS,EepromData);
  if (EepromData.magic != EEPROM_MAGIC)
  {
#endif  
    EepromData.magic = EEPROM_MAGIC;
    EepromData.calibrated = false;
    EepromData.A = 0;
    EepromData.Amax = 0;
    EepromData.Amin = 0;
    EepromData.Amid = 0;
    EepromData.Apeak = 0;
    EepromData.B = 0;
    EepromData.Bmax = 0;
    EepromData.Bmin = 0;
    EepromData.Bmid = 0;
    EepromData.Bpeak = 0;
    EepromData.Bnormal = 0;
    writeEepromData();
#ifndef RESET_EEPROM
  }
#endif  
}

//--------------------------------------------------------------------
//Shut down OLED and put ATtiny to sleep
//Will wake up when LEFT button is pressed
void goToSleep() 
{
  u8g2.clearBuffer();         // clear the internal memory
  u8g2.sendBuffer();          // transfer internal memory to the display
  //u8g2.setPowerSave(true);
  set_sleep_mode(SLEEP_MODE_PWR_DOWN);  // sleep mode is set here
  sleep_enable();
  sleep_mode();                         // System actually sleeps here
  sleep_disable();                      // System continues execution here when watchdog timed out
  //u8g2.setPowerSave(false);
  //wait until release
  while (digitalRead(SWITCH_PIN) == LOW);
  showSplashScreen();
  measureTimeout = millis();
  sleepTimeout = millis() + SHUTDOWN_TIME;
}

Credits

John Bradnam

John Bradnam

146 projects • 179 followers
Thanks to lingib .

Comments