├── .gitignore ├── extras ├── IO22D08 _8ch Pro Mini PLC_ display.ods ├── 4ch_Pro_Mini_delay_relay(Schematic).pdf ├── 8ch_Pro_Mini_delay_relay(Schematic).pdf ├── IO22D08 8ch Pro mini PLC Instructions.pdf └── display.md ├── .github └── workflows │ └── arduino-lint.yml ├── keywords.txt ├── library.properties ├── LICENSE ├── README.md ├── IO22_IO_Board.h ├── IO22_IO_Board.cpp └── examples ├── IO22D08Timers └── IO22D08Timers.ino └── IO22D08TimersAndFrequencySwitch └── IO22D08TimersAndFrequencySwitch.ino /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /extras/IO22D08 _8ch Pro Mini PLC_ display.ods: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njh/IO22_IO_Board/main/extras/IO22D08 _8ch Pro Mini PLC_ display.ods -------------------------------------------------------------------------------- /extras/4ch_Pro_Mini_delay_relay(Schematic).pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njh/IO22_IO_Board/main/extras/4ch_Pro_Mini_delay_relay(Schematic).pdf -------------------------------------------------------------------------------- /extras/8ch_Pro_Mini_delay_relay(Schematic).pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njh/IO22_IO_Board/main/extras/8ch_Pro_Mini_delay_relay(Schematic).pdf -------------------------------------------------------------------------------- /extras/IO22D08 8ch Pro mini PLC Instructions.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njh/IO22_IO_Board/main/extras/IO22D08 8ch Pro mini PLC Instructions.pdf -------------------------------------------------------------------------------- /.github/workflows/arduino-lint.yml: -------------------------------------------------------------------------------- 1 | name: arduino/arduino-lint-action 2 | on: [push, pull_request] 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: arduino/arduino-lint-action@v2 9 | with: 10 | library-manager: update 11 | -------------------------------------------------------------------------------- /keywords.txt: -------------------------------------------------------------------------------- 1 | IO22D08 KEYWORD1 2 | begin KEYWORD2 3 | displayNumber KEYWORD2 4 | setColon KEYWORD2 5 | toggleColon KEYWORD2 6 | displayCharacter KEYWORD2 7 | displayMessage KEYWORD2 8 | refreshDisplayAndRelays KEYWORD2 9 | enableRelays KEYWORD2 10 | disableRelays KEYWORD2 11 | relaySet KEYWORD2 12 | relayGet KEYWORD2 13 | relayNumToMask KEYWORD2 14 | relaySetN KEYWORD2 15 | relayIsOn KEYWORD2 16 | -------------------------------------------------------------------------------- /library.properties: -------------------------------------------------------------------------------- 1 | name=IO22_IO_Board 2 | version=1.0.2 3 | author=bdlow 4 | maintainer=bdlow 5 | sentence=Drive the Eletechsup IO22 family of I/O boards: IO22D08 and IO22C04 6 | paragraph=The Eletechsup IO22C04 and IO22D08 are I/O boards for an Arduino Pro Mini; they provide 4/8 x relay outputs (10A NO/NC outputs) + LED per channel, 4/8 x optically isolated inputs, 4 x pushbuttons, 4 x 9-segment LED display (88:88), handy for time/state info. 7 | category=Signal Input/Output 8 | url=https://github.com/af3556/IO22D08 9 | architectures=* 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ben 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IO22D08 Arduino Library 2 | 3 | The Eletechsup IO22D08 is an I/O board for an Arduino Pro Mini; it provides: 4 | 5 | - 8 x relay outputs (10A NO/NC outputs) + LED per channel 6 | - 8 x optically isolated inputs 7 | - 4 x pushbuttons 8 | - 4 x 9-segment LED display (88:88), handy for time/state info 9 | 10 | This project contains an Arduino library that can be used to interact with the 11 | IO22D08 hardware and an example sketch that exercises the library. 12 | 13 | Eletechsup appear to manufacture a number of related products, including the 14 | IO22C04 (4 relay/input version of the IO22D08) and other boards that use a 15 | Nano and provide an RS485 I/O interface, and/or 4-20mA inputs and so on. This 16 | appears to be their official web site and AliExpress store: 17 | 18 | - https://485io.com/expansion-board-c-14/ 19 | - https://eletechsupeletechsup.aliexpress.com/ 20 | 21 | These seem to have the same basic design: inputs directly connected to the 22 | Arduino, outputs (Darlington transistor array such as the ULN2803A) and LED 23 | display driven via a chain of 74HC595D shift registers. In some cases the relays 24 | are driven directly and only the display via shift registers. This library could 25 | likely be readily adapted to work with these other boards. 26 | 27 | ## Wokwi Emulation 28 | 29 | A Wokwi emulation of the significant parts of the board is here: https://wokwi.com/projects/410684313849767937 30 | 31 | The following shell pipeline below assembles this project's example into a form suitable for Wokwi: 32 | 33 | ```shell 34 | cat IO22D08.cpp | sed '/#include "IO22D08.h"/{ 35 | s/#include "IO22D08.h"// 36 | r IO22D08.h 37 | }' | ( cat; sed 's/#include "IO22D08.h"//' examples/IO22D08.ino ) | pbcopy 38 | ``` 39 | -------------------------------------------------------------------------------- /IO22_IO_Board.h: -------------------------------------------------------------------------------- 1 | #ifndef IO22D08_h 2 | 3 | #define IO22D08_h 4 | 5 | #include "Arduino.h" 6 | 7 | class IO22D08 8 | { 9 | // this class wraps the IO22D08 hardware 10 | // - TODO: generalise to support the IO22C04 variant? 11 | // - the IO22C04 has its four relay outputs directly connected to the 12 | // micro; i.e only has two shift registers for the display 13 | 14 | public: 15 | static const uint8_t numDisplayDigits = 4; 16 | static const uint8_t numRelays = 8; 17 | static const uint8_t numInputs = 8; 18 | static const uint8_t numButtons = 4; 19 | 20 | static const uint8_t numDisplayMessages = 4; 21 | static const uint8_t MESSAGE_BLANK = 0; 22 | static const uint8_t MESSAGE_ON = 1; 23 | static const uint8_t MESSAGE_OFF = 2; 24 | static const uint8_t MESSAGE_ERR = 3; 25 | 26 | IO22D08(); 27 | void begin(); 28 | 29 | void displayNumber(uint16_t n); 30 | void setColon(bool state); 31 | void toggleColon(); 32 | void displayCharacter(size_t n, uint8_t c); 33 | void displayMessage(uint8_t m); 34 | 35 | // relay masks 36 | static const uint8_t RELAY1 = 1<<1; 37 | static const uint8_t RELAY2 = 1<<2; 38 | static const uint8_t RELAY3 = 1<<3; 39 | static const uint8_t RELAY4 = 1<<4; 40 | static const uint8_t RELAY5 = 1<<5; 41 | static const uint8_t RELAY6 = 1<<6; 42 | static const uint8_t RELAY7 = 1<<7; 43 | static const uint8_t RELAY8 = 1<<0; 44 | static const uint8_t RELAYS_ALL = 0xFF; 45 | static const uint8_t RELAYS_NONE = 0xFF; 46 | static const uint8_t RELAY_ON = 0xFF; 47 | static const uint8_t RELAY_OFF = 0x00; 48 | 49 | void refreshDisplayAndRelays(); 50 | void enableRelays(); 51 | void disableRelays(); 52 | 53 | void relaySet(uint8_t mask, uint8_t state); 54 | uint8_t relayGet(uint8_t mask); 55 | // relayNum = simple numerical sequence, e.g. 3 (meaning RELAY3) 56 | uint8_t relayNumToMask(uint8_t n); 57 | void relaySetN(uint8_t relayNum, bool state); 58 | bool relayIsOn(uint8_t relayNum); 59 | 60 | const uint8_t inputPins[numInputs] {2, 3, 4, 5, 6, A0, 12, 11}; // IN1-8 61 | const uint8_t buttonPins[numButtons] {7, 8, 9, 10}; // K1-K4/B1-B4 62 | 63 | protected: 64 | 65 | // board connections 66 | // ref. circuit diagram for labels 67 | // - latch and clock are shared across the three shift registers 68 | // - no idea why the board designers didn't use the hardware serial pins (SPI) 69 | const uint8_t _latchPin = A2; 70 | const uint8_t _clockPin = A3; 71 | // - data is shifted out to the first register 72 | const uint8_t _dataPin = 13; 73 | 74 | // - relay shift register (U5) output enable; active low 75 | const uint8_t _relayOEpin = A1; 76 | 77 | uint16_t _displayBuffer[numDisplayDigits]; // display shift register buffer (n digits x 16bits ea.) 78 | uint8_t _relayBuffer = 0; // relay shift register buffer 79 | bool _displayColon = false; // enable the display colon 80 | 81 | // constexpr should work? but results in the linker complaining: 82 | // "undefined reference to `IO22D08::characters" on dereference 83 | // when being used as: digitBuffer = characters[c]; 84 | // - guessing this is due to the AVR's memory architecture (and mixing thereof) ¯\_(ツ)_/¯ 85 | // benefit of constexpr, if it worked, would be not having to specify the size of the array 86 | // constexpr static uint16_t characters[] = 87 | // - see display.md for details on the 7-segment display and how the _characters 88 | // constants are calculated 89 | const uint16_t _characters[17] = 90 | { 91 | 0x2008, // 0 92 | 0x7A08, // 1 93 | 0xE000, // 2 94 | 0x6200, // 3 95 | 0x3A00, // 4 96 | 0x2210, // 5 97 | 0x2010, // 6 98 | 0x6A08, // 7 99 | 0x2000, // 8 100 | 0x2200, // 9 101 | 0xFA18, // 10 ' ' (i.e. blank) 102 | 0x2008, // 11 O 103 | 0x7810, // 12 n 104 | 0xA810, // 13 F 105 | 0xA010, // 14 E 106 | 0xF810, // 15 r 107 | 0xF218, // 16 _ 108 | }; 109 | 110 | // to enable a digit the appropriate K1-K4 bit needs to be set high 111 | // - if the display were common cathode these constants would be 112 | // pre-inverted to skip the XOR that would otherwise be required 113 | // - in keeping with the button sequencing, digit 1 is the left-most digit, 114 | // 4 the right-most 115 | uint16_t _digitSelect[numDisplayDigits] = 116 | { 117 | 0x0400, // K1 (left-most) 118 | 0x0002, // K2 119 | 0x0004, // K3 120 | 0x0020, // K4 (right-most) 121 | }; 122 | // DP = U3:Q5; it'll get "mixed in" to each digit 123 | // for the IO22D08 board only DP2 and DP3 are connected as 'colon' LEDs 124 | const uint16_t _dpSegment = 0xDFFF; 125 | 126 | const uint8_t _displayMessages[numDisplayMessages][numDisplayDigits] = 127 | { 128 | {10, 10, 10, 10}, // ' ' 129 | {10, 10, 11, 12}, // ' On' 130 | {10, 11, 13, 13}, // ' OFF' 131 | {10, 14, 15, 15}, // ' Err' 132 | }; 133 | 134 | void _updateDigit(size_t d, uint8_t c); 135 | void _updateDigitSelect(size_t n); 136 | void _updateColon(); 137 | 138 | }; 139 | 140 | 141 | #endif 142 | -------------------------------------------------------------------------------- /IO22_IO_Board.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | IO22D08 "8ch Pro Mini PLC" 3 | https://eletechsup.taobao.com/ (Chinese only) 4 | 5 | I/O board for an Arduino Pro Mini 5V (16MHz) 6 | - 8 x relay outputs (10A NO/NC outputs) + LED per channel 7 | - 8 x optically isolated inputs 8 | - tied high to 5V via 1k 9 | - 4 x pushbuttons 10 | - directly connected to Mini pins 7-10 11 | - 4 x 9-segment LED display (88:88), handy for time/state info 12 | 13 | - the "PLC" board uses 3x8-bit shift registers to drive the outputs (relays 14 | and LED display) using only 4 IOs (3 for the registers, 1 for output enable) 15 | - the shift register driving the relays has its output enable connected to 16 | the Mini (pin A1); presumably intended as a "shortcut" to disable the 17 | relays without having to shift in 0's 18 | - the other two shift registers are for the display 19 | - displays are also multiplexed (micro must refresh) 20 | - inputs are passed through to dedicated pins, not multiplexed 21 | 22 | - the Pro Mini's A4,A5 (I2C etc) and A6,A7 (ADC only) are not used by the 23 | PLC board 24 | 25 | - operating voltage: DC 12V 26 | - standby 12mA 27 | - w/ LED display: ~48mA 28 | - each relay +30mA; e.g. all 8 = +240, ~290mA total 29 | 30 | - simulation: https://wokwi.com/projects/410684313849767937 31 | 32 | - eletechsup apparently provided example code in a tarball distributed via 33 | MS OneDrive (!), dead as of 2024 34 | - some discussion here: https://forum.arduino.cc/t/io22d08-control-and-library/ 35 | - a third-party (aka Noiasca on arduino.cc) page: 36 | https://werner.rothschopf.net/microcontroller/202104_arduino_pro_mini_relayboard_IO22D08_en.htm 37 | - in turn, that apparently incorporates work from http://www.canton-electronics.com 38 | (offline) and/or eletechsup 39 | 40 | - this work is a complete reimplementation, including: 41 | - documented / RE'd the operation of the shift registers that drive the 42 | relays and display 43 | - Arduino style guide for naming (i.e. camelCase) 44 | - added a "self test" at power on to verify the display is working as 45 | expected 46 | - documented example on using the Library 47 | 48 | - errata in previous works 49 | - the PLC schematic labelling leaves a _lot_ to be desired 50 | - button labels (K1-K4) are reversed on the board silk screen / labels 51 | - i.e. what's marked as "K4" on the board is connected to pin 7 on the micro 52 | - code provided by eletechsup (?) refers to the display both as common anode 53 | and common cathode; it's actually common anode 54 | - the code from werner.rothschopf.net references IC label that do not match 55 | the schematic in this repo 56 | - these issues only impact the display digit mappings / constants, all comes 57 | out in the wash 58 | 59 | 60 | - see display.md for details on the 7-segment display and how the _characters 61 | constants are calculated 62 | 63 | */ 64 | 65 | 66 | #include "Arduino.h" 67 | #include "IO22_IO_Board.h" 68 | 69 | IO22D08::IO22D08() {} 70 | 71 | void IO22D08::begin() { 72 | pinMode(_latchPin, OUTPUT); 73 | pinMode(_clockPin, OUTPUT); 74 | pinMode(_dataPin, OUTPUT); 75 | 76 | // board has pullups; even then leave this to the button library 77 | for (auto &i : inputPins) pinMode(i, INPUT_PULLUP); 78 | for (auto &i : buttonPins) pinMode(i, INPUT_PULLUP); 79 | 80 | disableRelays(); // start off, off 81 | pinMode(_relayOEpin, OUTPUT); 82 | } 83 | 84 | void IO22D08::_updateDigitSelect(size_t n) 85 | { 86 | // set the relevant digit select bit (common anode) 87 | // - needs to be done after every buffer update 88 | // - in keeping with the button sequencing, digit 1 is the left-most digit, 89 | // 4 the right-most 90 | _displayBuffer[n] |= _digitSelect[n]; 91 | } 92 | 93 | // write a given character to a specific digit of the display 94 | // - n: digit position (0..numDisplayDigits-1) 95 | // - c: character (index into _characters[]) 96 | void IO22D08::_updateDigit(size_t n, uint8_t c) 97 | { 98 | _displayBuffer[n] = _characters[c]; 99 | } 100 | 101 | void IO22D08::_updateColon() 102 | { 103 | // mix in the colon (DP segments on digits 1,2) as required 104 | if (_displayColon) 105 | { 106 | _displayBuffer[1] &= _dpSegment; 107 | _displayBuffer[2] &= _dpSegment; 108 | } 109 | else 110 | { 111 | _displayBuffer[1] |= ~_dpSegment; 112 | _displayBuffer[2] |= ~_dpSegment; 113 | } 114 | _updateDigitSelect(1); 115 | _updateDigitSelect(2); 116 | } 117 | 118 | void IO22D08::displayCharacter(size_t n, uint8_t c) 119 | { 120 | _updateDigit(n, c); 121 | _updateDigitSelect(n); 122 | _updateColon(); 123 | } 124 | 125 | void IO22D08::displayNumber(uint16_t number) 126 | { 127 | for (size_t n = numDisplayDigits; n-- > 0;) 128 | { 129 | _updateDigit(n, number % 10); 130 | _updateDigitSelect(n); 131 | number /= 10; 132 | } 133 | _updateColon(); 134 | } 135 | 136 | void IO22D08::displayMessage(uint8_t m) 137 | { 138 | for (size_t n = 0; n < numDisplayDigits; n++) 139 | { 140 | _updateDigit(n, _displayMessages[m][n]); 141 | _updateDigitSelect(n); 142 | } 143 | _updateColon(); 144 | } 145 | 146 | void IO22D08::setColon(bool state) 147 | { 148 | _displayColon = state; 149 | _updateColon(); 150 | } 151 | 152 | void IO22D08::toggleColon() 153 | { 154 | _displayColon ^= true; 155 | _updateColon(); 156 | } 157 | 158 | 159 | void IO22D08::refreshDisplayAndRelays() 160 | { 161 | // shift out the entire display: each digit preceded by the relay register 162 | for (auto &d : _displayBuffer) 163 | { 164 | digitalWrite(_latchPin, LOW); 165 | // - shiftOut() only accepts a byte at a time 166 | shiftOut(_dataPin, _clockPin, MSBFIRST, lowByte(d)); // U4 167 | shiftOut(_dataPin, _clockPin, MSBFIRST, highByte(d)); // U3 168 | shiftOut(_dataPin, _clockPin, MSBFIRST, _relayBuffer); // U5 169 | digitalWrite(_latchPin, HIGH); 170 | } 171 | } 172 | 173 | 174 | // the PLC board connects the relay shift register's output enable (OE) 175 | // to _relayOEpin; when disabled (high impedance) the ULN2803 transistor 176 | // array that actually drives the relay coils will turn off all relays 177 | // - this is quicker than having to shift in 0's to the relay SR, and 178 | // also allows the relays to be disabled and re-enabled back to their 179 | // prior state 180 | void IO22D08::enableRelays() { 181 | digitalWrite(_relayOEpin, LOW); 182 | } 183 | void IO22D08::disableRelays() { 184 | digitalWrite(_relayOEpin, HIGH); 185 | } 186 | 187 | // the relays are managed en-masse: as a set via the shift-register, not 188 | // individually by dedicated output pins; hence the use of octet-wide operations 189 | // here instead of separate bit*() ops 190 | // 191 | // set relays given a mask to select the desired relay(s) and the desired 192 | // state for the selected relays 193 | // mask bits are the relays to be changed 194 | // state bits are on/off 195 | // e.g. relaySet(RELAY2, RELAY_ON) will turn on relay 2 196 | // e.g. relaySet(RELAY2, RELAY2) will also turn on relay 2 197 | // e.g. relaySet(RELAY2, true) will turn on a relay _other_ than relay 2 198 | // e.g. relaySet(RELAY2, RELAY_OFF) will turn off relay 2 199 | // e.g. relaySet(RELAY2, 0xF1) will also turn off relay 2, but not in a clear way 200 | // e.g. relaySet(RELAYS_NONE, ...) will make no changes (no relays selected) 201 | // e.g. relaySet(RELAYS_ALL, RELAY_OFF) will turn off all relays 202 | // e.g. relaySet(R_ALL, RELAY_OFF) will turn off all relays 203 | // e.g. relaySet(R1+R3+R6, RELAY_ON) will turn on relays 1, 3 and 6 204 | // relayGet will return the state of the given relay(s) 205 | // e.g. relayGet(RELAY2) will return non-zero (RELAY2) if relay 2 is on 206 | // e.g. relayGet(R1+R3+R6) will non-zero if any of relays 1, 3 and 6 are on 207 | 208 | uint8_t IO22D08::relayNumToMask(uint8_t relayNum) 209 | { 210 | // relays are mapped to SR outputs as 76543218 211 | // i.e. relay numbers 8,1-7 => bits 0,1-7 212 | if (relayNum >= 8) relayNum = 0; 213 | return (1< 30 | using namespace ace_button; 31 | 32 | IO22D08 io22d08; // create an instance of the relay board 33 | 34 | // AceButton is used to handle both the buttons K1-K4 and the inputs IN2-IN8 35 | // - relays (or timers) are switched via button handler callbacks 36 | ButtonConfig buttonConfig; 37 | AceButton buttons[io22d08.numButtons]; 38 | 39 | ButtonConfig inputConfig; 40 | AceButton inputs[io22d08.numInputs]; 41 | 42 | 43 | // simple timer controlling one or more relays that turns them on with start() 44 | // and, when the elapsed time exceeds the given timeout, off via tick() 45 | // - relayMasks are used to allow multiple relays to be switched together 46 | class RelayTimer 47 | { 48 | protected: 49 | uint32_t _previousMillis = 0; 50 | uint8_t _relayMask = 0; 51 | uint16_t _timeout = 1000UL; 52 | bool _isActive = false; 53 | 54 | public: 55 | RelayTimer() {} 56 | 57 | void setTimeout(uint8_t rm, uint16_t t) 58 | { 59 | _relayMask = rm; 60 | _timeout = t * 1000UL; 61 | } 62 | 63 | void start() 64 | { 65 | Serial.print(F("T(")); 66 | Serial.print(_relayMask); 67 | Serial.println(F("):ON")); 68 | _previousMillis = millis(); 69 | _isActive = true; 70 | // if _relayMask is 0, will be a no-op 71 | io22d08.relaySet(_relayMask, io22d08.RELAY_ON); 72 | } 73 | 74 | void stop() 75 | { 76 | Serial.print(F("T(")); 77 | Serial.print(_relayMask); 78 | Serial.println(F("):OFF")); 79 | _isActive = false; 80 | io22d08.relaySet(_relayMask, io22d08.RELAY_OFF); 81 | } 82 | 83 | void tick() 84 | { 85 | if (_isActive) 86 | if (millis() - _previousMillis > _timeout) stop(); 87 | } 88 | 89 | uint16_t getTimeRemaining() 90 | { 91 | if (_isActive) 92 | return _timeout - (millis() - _previousMillis); 93 | return -1; // maxtime 94 | } 95 | }; 96 | 97 | const size_t numRelayTimers = io22d08.numRelays; // for this demo, as many timers as relays 98 | RelayTimer relayTimers[numRelayTimers]; 99 | 100 | // handler for the onboard buttons K1-K4 101 | // - in this example a short-press starts the timer for relays 1-4 and a 102 | // long-press for relays 5-8 103 | // - the link between buttons and relays (the button ID) is established via the 104 | // AceButton's init 105 | void buttonHandler(AceButton* button, uint8_t eventType, uint8_t /*buttonState*/) 106 | { 107 | uint8_t buttonId = button->getId(); 108 | 109 | Serial.print(F("B")); 110 | Serial.print(buttonId); 111 | Serial.print(F(":")); 112 | Serial.println(AceButton::eventName(eventType)); 113 | 114 | switch (eventType) { 115 | case AceButton::kEventLongPressed: 116 | buttonId += 4; // "virtual" buttons 8-5 117 | [[fallthrough]]; 118 | case AceButton::kEventClicked: 119 | // buttonIDs are not necessarily aligned with timer indices 120 | relayTimers[(buttonId-1)%numRelayTimers].start(); // button N => timer N-1 121 | break; 122 | } 123 | } 124 | 125 | // handler for the digital inputs 126 | // - in this example, simply switch the corresponding relay directly on/off, 127 | // on short-press/release; start a timer on "double-click" 128 | // - the link between inputs and relays (the button ID) is established via the 129 | // AceButton's init 130 | void inputHandler(AceButton* button, uint8_t eventType, uint8_t /*buttonState*/) 131 | { 132 | uint8_t inputNum = button->getId(); 133 | Serial.print(F("I")); 134 | Serial.print(inputNum); 135 | Serial.print(F(":")); 136 | Serial.println(AceButton::eventName(eventType)); 137 | 138 | switch (eventType) { 139 | case AceButton::kEventPressed: 140 | io22d08.relaySetN(inputNum, true); 141 | break; 142 | case AceButton::kEventReleased: 143 | io22d08.relaySetN(inputNum, false); 144 | break; 145 | case AceButton::kEventClicked: 146 | io22d08.relaySetN(inputNum, !io22d08.relayIsOn(inputNum)); 147 | break; 148 | case AceButton::kEventDoubleClicked: 149 | // buttonIDs are not necessarily aligned with timer indices 150 | relayTimers[(inputNum-1)%numRelayTimers].start(); // input N => timer N-1 151 | break; 152 | } 153 | } 154 | 155 | void setup() { 156 | Serial.begin(9600); 157 | io22d08.begin(); 158 | io22d08.displayMessage(io22d08.MESSAGE_BLANK); // clear the display 159 | io22d08.enableRelays(); 160 | 161 | Serial.println(F("\nIO22D08")); 162 | 163 | Serial.print(F("init buttons: K1-K4 ")); 164 | buttonConfig.setEventHandler(buttonHandler); 165 | buttonConfig.setFeature(ButtonConfig::kFeatureClick); 166 | buttonConfig.setFeature(ButtonConfig::kFeatureLongPress); 167 | buttonConfig.setClickDelay(300); 168 | for (size_t b = 0; b < io22d08.numButtons; b++) 169 | { 170 | // button numbers/IDs count from 1, and are normally high (active low) 171 | buttons[b].init(&buttonConfig, io22d08.buttonPins[b], HIGH, b+1); 172 | Serial.print(b+1); 173 | } 174 | Serial.println(F("✔️")); 175 | 176 | Serial.print(F("init digital inputs: IN1-IN8 ")); 177 | // the following provides: 178 | // - single pulse = relay pulse (stretched to accommodate double-click) 179 | // - long pulse = relay pulse (i.e. on for as long as input is active) 180 | // - double-pulse = start the timer 181 | // - long-press = turn on the relay, leave it on 182 | inputConfig.setEventHandler(inputHandler); 183 | inputConfig.setFeature(ButtonConfig::kFeatureClick); 184 | inputConfig.setFeature(ButtonConfig::kFeatureLongPress); 185 | inputConfig.setFeature(ButtonConfig::kFeatureDoubleClick); 186 | inputConfig.setFeature(ButtonConfig::kFeatureSuppressAll); 187 | for (size_t i = 0; i < io22d08.numInputs; i++) 188 | { 189 | // button numbers/IDs count from 1, and are normally-high (active low) 190 | inputs[i].init(&inputConfig, io22d08.inputPins[i], HIGH, i+1); 191 | Serial.print(i+1); 192 | } 193 | Serial.println(F("✔️")); 194 | 195 | // set some demo timer values 196 | // update numRelayTimers to reflect the number of timers being used 197 | Serial.print(F("set relay timers: ")); 198 | size_t i = 0; 199 | relayTimers[i++].setTimeout(io22d08.RELAY1, 4); 200 | relayTimers[i++].setTimeout(io22d08.RELAY2, 6); 201 | relayTimers[i++].setTimeout(io22d08.RELAY3, 8); 202 | relayTimers[i++].setTimeout(io22d08.RELAY4, 10); 203 | relayTimers[i++].setTimeout(io22d08.RELAY5, 12); 204 | relayTimers[i++].setTimeout(io22d08.RELAY6, 16); 205 | relayTimers[i++].setTimeout(io22d08.RELAY7, 20); 206 | relayTimers[i++].setTimeout(io22d08.RELAY8, 30); 207 | Serial.print(i); 208 | Serial.print("/"); 209 | Serial.print(numRelayTimers); 210 | Serial.println(F("✔️")); 211 | 212 | pinMode(A4, OUTPUT); // loop() interval measurement 213 | } 214 | 215 | uint16_t _getMinTimeRemaining(uint16_t mtr, RelayTimer* timers, size_t nTimers) 216 | { 217 | for (size_t n = 0; n < nTimers; n++) 218 | { 219 | uint16_t tr = timers[n].getTimeRemaining(); 220 | if (tr < mtr) mtr = tr; 221 | } 222 | return mtr; 223 | } 224 | 225 | void loop() 226 | { 227 | static unsigned long previousMillis[] = {0, 0}; 228 | const uint8_t PREVIOUS_MILLIS_COLON = 0; // colon update 229 | const uint8_t PREVIOUS_MILLIS_DISPLAY = 1; // display update 230 | unsigned long currentMillis; 231 | currentMillis = millis(); 232 | 233 | // colon flash is asynchronous to timer and display updates 234 | if (currentMillis - previousMillis[PREVIOUS_MILLIS_COLON] > 500) 235 | { 236 | previousMillis[PREVIOUS_MILLIS_COLON] = currentMillis; 237 | io22d08.toggleColon(); 238 | } 239 | 240 | // update display 241 | if (currentMillis - previousMillis[PREVIOUS_MILLIS_DISPLAY] > 250) 242 | { 243 | previousMillis[PREVIOUS_MILLIS_DISPLAY] = currentMillis; 244 | // display the (active) timer that is expiring next (i.e. lowest delta) 245 | io22d08.displayMessage(io22d08.MESSAGE_BLANK); // clear the display 246 | uint16_t mtr = UINT16_MAX; 247 | mtr = _getMinTimeRemaining(mtr, relayTimers, numRelayTimers); 248 | if (mtr != UINT16_MAX) 249 | { 250 | io22d08.displayNumber(mtr/1000UL + 1); // +1: crude ceil() 251 | } 252 | } 253 | 254 | for (auto & b : buttons) b.check(); 255 | for (auto & i : inputs) i.check(); 256 | for (auto & t : relayTimers) t.tick(); 257 | 258 | io22d08.refreshDisplayAndRelays(); 259 | digitalWrite(A4, digitalRead(A4)==HIGH?LOW:HIGH); // to measure loop time 260 | } 261 | -------------------------------------------------------------------------------- /extras/display.md: -------------------------------------------------------------------------------- 1 | # About The Display 2 | 3 | The 4 digit 7-segment display is connected to two 8-bit shift registers (SR). A 4 | third shift register is used to control the relays. The SRs are connected 5 | serially with the 24-bits of data shifting through as: 6 | 7 | U5 (relays) > U3 (display) > U4 (display) 8 | 9 | A 7-segment display can show the digits 0-9 as well as a handful of other 10 | characters; 4 x 7-segment digits can display more than just numbers, e.g. "ON", 11 | "OFF", "Err". 12 | 13 | Going from characters in code to displaying them involves three main parts: 14 | 15 | 1) mapping the shift register outputs to display pins (segments+digit commons) 16 | - this is largely determined by the board layout (essentially arbitrary) 17 | 18 | 2) defining a "font": mapping characters to display segments, e.g. 19 | the character "3" = segments A,B,C,D,G 20 | - this can be (arbitrarily) represented as a 8-bit value in the bit 21 | order `DP A B C D E F G` (DP = decimal point for that digit, if present) 22 | - the MAX7219 has a 'font' ready to go (e.g. https://github.com/JemRF/max7219) 23 | - DP is omitted from the character fonts as it's not a persistent part of most 24 | characters 25 | - for the PLC board: the display does not actually have DPs for each digit, 26 | rather, there are DPs 2 and 3 are the lower and upper colon LEDs 27 | - DP1 and 4 appear to be not connected 28 | 29 | 3) the 4-digit display is multiplexed: segment cathode connections are shared 30 | across all digits, with a separate common anode for each digit 31 | - a "solid" 4-digit display results when each digit is individually updated 32 | quickly enough to fool the eye (~<50ms refresh?, so ~10ms/digit) 33 | 34 | Ultimately, a specific bit sequence needs to be loaded to the shift registers 35 | then latched to drive a single digit/character, repeated for all digits. 36 | 37 | In the following, we pre-calculate the values of the combination of the 38 | shift-register-to-display-pin and character-font definitions such that writing 39 | the resulting "magic number" to the pair of display shift registers will result 40 | in the desired character being displayed in the desired position. 41 | 42 | This approach allows defining a relatively compact set of symbols for each 43 | character which can then be combined via a single bit operation with the desired 44 | digit select bit (and another for the decimal point as desired) to generate a 45 | complete bit pattern for each digit position. 46 | 47 | The alternative is to do a bunch of bit operations on a more obviously defined 48 | character-segment mapping (font); given the font will never change it's more 49 | efficient in time and space to take the pre-calculated approach (on space 50 | efficiency: the pre-calculated shift register data is 16 bits per character vs. 51 | 8 for the font, so technically less space efficient for data but saves on code). 52 | Putting any thought into saving a few bytes on a micro that has oodles of spare 53 | memory is of course the very epitome of over-optimisation but it beats doing 54 | sudoku. 55 | 56 | ## Common Anode / Common Cathode 57 | 58 | Some doc (code comments) for this board incorrectly state the display is common 59 | cathode; it's actually common anode. Interestingly it's a moot point as the 60 | entirety of the display (every pin) is driven via the shift registers and thus 61 | from software: the only difference would be the values of the various constants. 62 | 63 | ## 1. Mapping the Shift Register Outputs to Display Pins 64 | 65 | The mapping from relays and display pins to shift register outputs is as follows 66 | (from the schematic): 67 | 68 | ```text 69 | Relays 4-digit, 7-segment LED display 70 | R1 - U5:Q1 c - U3:Q7 x - U4:Q7 71 | R2 - U5:Q2 f - U3:Q6 x - U4:Q6 72 | R3 - U5:Q3 DP - U3:Q5 K4 - U4:Q5 73 | R4 - U5:Q4 a - U3:Q4 b - U4:Q4 74 | R5 - U5:Q5 d - U3:Q3 g - U4:Q3 75 | R6 - U5:Q6 K1 - U3:Q2 K3 - U4:Q2 76 | R7 - U5:Q7 e - U3:Q1 K2 - U4:Q1 77 | R8 - U5:Q0 x - U3:Q0 x - U4:Q0 78 | 79 | - x = don't care (not connected) 80 | ``` 81 | 82 | Transforming that into a 24-bit word: 83 | 84 | ```text 85 | | _relayBuffer | digitBuffer | 86 | | Relays (U5) | Display (U3) | Display (U4) | 87 | | 7 6 5 4 3 2 1 0 | 7 6 5 4 3 2 1 0 | 7 6 5 4 3 2 1 0 | 88 | | R7 R6 R5 R4 R3 R2 R1 R8 | c f DP a d K1 e x x x K4 c g K3 K2 x | 89 | ``` 90 | 91 | The ordering here reflects the physical arrangement of the shift registers, 92 | which is U5 (relays) > U3 (display) > U4 (display) (i.e. not U5>U4>U3). 93 | 94 | The relays are physically labelled as 1 through 8; coils (and corresponding 95 | LEDs) are driven from the 12V supply via the ULN2803. The ULN2803 is in turn 96 | driven by the shift register U5. The connections are as noted in the above 97 | `_relayBuffer` map (i.e. Q0 = R8, Q1 = R1 ... Q7 = R7). The interface provided 98 | in `relaySet()` and `relaySetN()` accounts for this sequencing. 99 | 100 | Aside when powering from 12V: the serial programmer's Vcc (5V) should not be 101 | connected, otherwise the IO22D08's 5V regulator will fight with the connected 102 | USB port. 103 | 104 | ### Worked Example 105 | 106 | Relays 1, 3, 4 on, and a digit '3' displayed in position 1 107 | 108 | - i.e. segments (a, b, c, d, g), K1 enabled 109 | - logic levels: "H" = high/enabled/active/on, "L" = low/disabled/inactive/off 110 | 111 | The relays are essentially active-high; H = 1 / relay-on. 112 | 113 | Focusing only on the display for the moment: a common anode display requires the 114 | segments to be driven active-low and the digit common (K) pin driven 115 | active-high. Thus, the segment bits presented by the SR to the display have to 116 | inverted to make them active-low. 117 | 118 | To flip only the segment bits (incl. DP), apply an XOR mask as below (assigning 119 | the don't cares to 0) 120 | 121 | ```text 122 | | _relayBuffer | digitBuffer | 123 | | Relays (U5) | Display (U3) | Display (U4) | 124 | | 7 6 5 4 3 2 1 0 | 7 6 5 4 3 2 1 0 | 7 6 5 4 3 2 1 0 | 125 | | R7 R6 R5 R4 R3 R2 R1 R8 | c f DP a d K1 e x x x K4 c g K3 K2 x | 126 | | | H L L H H H L x x x L H H L L x | 127 | | 1 1 1 1 1 0 1 0 0 0 0 1 1 0 0 0 | 128 | (= 0x__FA18) 129 | ``` 130 | 131 | If the display were common cathode, the K1-K4 bits would need to be flipped; 132 | 133 | ```text 134 | | 0 0 0 0 0 1 0 0 0 0 1 0 0 1 1 0 | 135 | (= 0x__0426) 136 | ``` 137 | 138 | Continuing the example (digit '3' displayed in position 1): 139 | 140 | ```text 141 | | _relayBuffer | digitBuffer | 142 | | Relays (U5) | Display (U3) | Display (U4) | 143 | | 7 6 5 4 3 2 1 0 | 7 6 5 4 3 2 1 0 | 7 6 5 4 3 2 1 0 | 144 | | R7 R6 R5 R4 R3 R2 R1 R8 | c f DP a d K1 e x x x K4 c g K3 K2 x | 145 | | L L L H H L H L | H L L H H H L x x x L H H L L x | 146 | - assigning the don't cares to 0: 147 | | 0 0 0 1 1 0 1 0 | 1 0 0 1 1 1 0 0 0 0 0 1 1 0 0 0 | 148 | XOR with the segment active-low mask: 149 | | 1 1 1 1 1 0 1 0 0 0 0 1 1 0 0 0 | 150 | = 151 | | 0 0 0 1 1 0 1 0 | 0 1 1 0 0 1 1 0 0 0 0 0 0 0 0 0 | 152 | (= 0x1A6600) 153 | ``` 154 | 155 | Writing this 24-bit value out to the shift registers via the data pin will 156 | result in relays 1, 3, 4 on, and a digit '3' displayed in position 1. 157 | (Of course, it's not _quite_ that simple: the 74HC595 shift register is 158 | most significant bit (MSb) first - Q7-Q0 have to be shifted in with Q7 first, 159 | so you can't just `shiftout()` the 24 bits in one go, not that you can do that 160 | anyway as `shiftout()` handles only one byte at a time. See below re. the 595.) 161 | 162 | ### Four x Digit Buffers = Display Buffer 163 | 164 | Now, recall that the 4 digits in the display are multiplexed: the digits (shift 165 | register outputs) have to be cycled through all four "quickly" for the eye to 166 | perceive a solid display. It's overall simplest and most efficient to decouple 167 | the display rendering (character generation to SR bit values) from the display 168 | refresh (shifting out the SR bits), by way of complete display buffer that holds 169 | the entire 4-digit bit stream (4 x 16 bits). Note the relay outputs are _not_ 170 | multiplexed and don't need to be refreshed, however the relay SR is serially in 171 | line with the display SRs: the same relay state _has_ to be shifted out simply 172 | to push the display bits out to the display SRs. Given the SR outputs only 173 | update when explicitly latched, there's no harm in this and indeed is an 174 | efficient use of the available hardware. 175 | 176 | There is no need to repeat the relay state in the display buffer, so they're 177 | kept separate right up until serialisation out the SR data pin. 178 | 179 | ### Maximum Refresh Rate 180 | 181 | The 24-bits of relay+display buffer, repeated for each of the 4 digits, results 182 | in sending out 96 bits every display refresh cycle. The PLC board layout 183 | precludes using the micro's hardware SPI, though Arduino's software `shiftout()` 184 | is still plenty fast enough: apparently it'll spit out 8 bits in ~0.1ms, or 185 | ~1.2ms for refreshing all four digits. That's more than fine for this 186 | application given we're not doing much work the rest of the time. If desired, 187 | there exist faster `shiftout()` implementations: 188 | https://github.com/RobTillaart/Arduino/tree/master/libraries/FastShiftOut 189 | http://nerdralph.blogspot.com/2015/03/fastest-avr-software-spi-in-west.html 190 | 191 | ### Some Details On The 74HC595 192 | 193 | tl'dr of this section: the 74HC595 is "natively" most significant bit (MSb), 194 | for an octet to be 1:1 reflected on the 595's Q7-Q0 outputs it has to be shifted 195 | in MSBFIRST. 196 | 197 | TI's 74HC595 data sheet labels the shift register outputs as Q_A through Q_H; 198 | some other vendors label them as Q0 through Q7, with Q1-Q7 conveniently lining 199 | up with pins 1-7; Q0 is over on pin 15. The PLC schematic uses the latter 200 | labelling. 201 | 202 | Conventional bit ordering is least significant bit (LSb) in the binary 1's 203 | place: the right-most bit 0 is least-significant; there's no inherent reason to 204 | require the shift register outputs to correspond to that but it'll be less 205 | confusing when Q7-Q0 maps to bits 7-0. 206 | 207 | The 74HC595 is "natively" most significant bit (MSb): after shifting in 8 bits, 208 | the Q7 output will contain the first (0'th) bit, and Q0 the last (7'th) bit. Put 209 | another way, if you wanted to reflect a uint8_t value in the micro on the shift 210 | register in the conventional LSb ordering, you'd have to shift that value in to 211 | the shift register MSBFIRST. 212 | 213 | i.e. if `hgfedcba` is a binary value with h-a representing a bit position (h-a 214 | ordering here also reflecting TI's labelling): 215 | 216 | ```text 217 | shiftOut(MSBFIRST, hgfedcba) -> Q7=h Q6=g ... Q1=b Q0=a 218 | ``` 219 | 220 | On the PLC board the pair of 8-bit shift registers used for the display are 221 | daisy chained with U4 following U3; the 16 bits `ponmlkji_hgfedcba` would need 222 | to go out as: 223 | 224 | ```text 225 | shiftOut(MSBFIRST, hgfedcba), shiftOut(MSBFIRST, ponmlkji) -> 226 | U3: Q7=p Q6=o ... Q1=j Q0=i 227 | U4: Q7=h Q6=g ... Q1=b Q0=a 228 | ``` 229 | 230 | That is, U3 will end up holding the upper 8 bits, U4 the lower. 231 | 232 | ### Pre-Calculating the Font+Register Mapping 233 | 234 | Carrying on from the Display-SR pin mapping above, the following reflects, per 235 | the schematic, the bit pattern associated with each pin on the display along 236 | with the 16-bit numerical representation of that pattern; also shown is the mask 237 | that selects all the segments/commons: (at this point these are all logic level, 238 | ignoring display common cathode/anode) 239 | 240 | ```text 241 | segments - IC:pin - SR pattern 242 | a - U3:Q4 - 00010000 00000000 (0x1000) 243 | b - U4:Q4 - 00000000 00010000 (0x0010) 244 | c - U3:Q7 - 10000000 00000000 (0x8000) 245 | d - U3:Q3 - 00001000 00000000 (0x0800) 246 | e - U3:Q1 - 00000010 00000000 (0x0200) 247 | f - U3:Q6 - 01000000 00000000 (0x4000) 248 | g - U4:Q3 - 00000000 00001000 (0x0008) 249 | DP - U3:Q5 - 00100000 00000000 (0x2000) 250 | mask 11111010 00011000 (0xFA18) 251 | 252 | commons (display digit select (aka DIG1-DIG4; see below)) 253 | K1 - U3:Q2 - 00000100 00000000 (0x0400) 254 | K2 - U4:Q1 - 00000000 00000010 (0x0002) 255 | K3 - U4:Q2 - 00000000 00000100 (0x0004) 256 | K4 - U4:Q5 - 00000000 00100000 (0x0020) 257 | mask 00000100 00100110 (0x0426) 258 | ``` 259 | 260 | - aside: the schematic reuses the labels K1-K4 for the buttons, those 261 | are connected directly to the micro and aren't relevant to the shift 262 | register processing 263 | 264 | Shift register outputs U3:Q0 and U4:Q0,Q6,Q7 are unused. 265 | 266 | A common anode display requires the segment to be driven _low_ (i.e. active 267 | low); the above (logic level) segment values need to be inverted when presented 268 | by the SR to the display. The commons are active-high, so stay as-is. 269 | 270 | ## 2. Defining A Font: Mapping Characters to Display Segments 271 | 272 | A 7-segment character + associated decimal point can be defined with 8-bits: 273 | 274 | ```text 275 | dp a b c d e f g 276 | ``` 277 | 278 | The MAX7219, and the library at https://github.com/JemRF/max7219, provide a 279 | 'font' ready to go. Lifting the desired characters from there: 280 | 281 | ```text 282 | 0b01111110, // 0 283 | 0b00110000, // 1 284 | 0b01101101, // 2 285 | 0b01111001, // 3 286 | 0b00110011, // 4 287 | 0b01011011, // 5 288 | 0b01011111, // 6 289 | 0b01110000, // 7 290 | 0b01111111, // 8 291 | 0b01111011, // 9 292 | 0b01111110, // O (same as 0) 293 | 0b00010101, // n 294 | 0b01000111, // F 295 | 0b01001111, // E 296 | 0b00000101, // r 297 | ``` 298 | 299 | - DP is always 0 as it's not a fixed part of any of the above characters 300 | 301 | To map the above patterns to the necessary shift register outputs requires 302 | combining the above character font with the previous shift-register-to-display- 303 | pin mapping, enabling every SR output that corresponds to every bit of the 304 | character. i.e. ORing all the SR values that correspond to an enabled font 305 | segment. This can be expressed as the bitwise sum-product of the two bit 306 | vectors. 307 | 308 | ```text 309 | For example for the character '4' = 0b00110011 = 310 | 0 x 0x2000 + // DP 311 | 1 x 0x1000 + // a 312 | 1 x 0x0010 + // b 313 | 1 x 0x8000 + ... 314 | for a final value of 0xC018. 315 | ``` 316 | 317 | Repeating the above for the whole character set (via a spreadsheet), we get: 318 | 319 | ```text 320 | 0xDA10, // 0 321 | 0x8010, // 1 322 | 0x1A18, // 2 323 | 0x9818, // 3 324 | 0xC018, // 4 325 | 0xD808, // 5 326 | 0xDA08, // 6 327 | 0x9010, // 7 328 | 0xDA18, // 8 329 | 0xD818, // 9 330 | 0xDA10, // O 331 | 0x8208, // n 332 | 0x5208, // F 333 | 0x5A08, // E 334 | 0x0208, // r 335 | ``` 336 | 337 | Aside: the digit selection bits K1-K4 are "mixed in" to the character data at 338 | display refresh time. 339 | 340 | We're not done yet: the above table is only the logic level values; still need 341 | to XOR the segment mask to flip the segment bits to active-low. The arrays in 342 | the code reflect these final values. 343 | 344 | In summary, the display is managed by: 345 | 346 | - _updateDigit() does the rendering: writes the relevant "magic number" 347 | constants corresponding to the desired characters/symbols into the buffer 348 | - the only time bit operations are needed are to "mix in" the DPs/colon 349 | - refreshDisplayAndRelays() cycles out the bitstream (could be triggered via 350 | an interrupt to maintain a consistent refresh period) 351 | 352 | ## Buttons and Inputs 353 | 354 | The 'K1-4' button and 'IN1-8' optocoupled inputs are active-low. 355 | -------------------------------------------------------------------------------- /examples/IO22D08TimersAndFrequencySwitch/IO22D08TimersAndFrequencySwitch.ino: -------------------------------------------------------------------------------- 1 | /* examples/IO22D08TimersAndFrequencySwitch/IO22D08TimersAndFrequencySwitch.ino 2 | 3 | The IO22D08 is an I/O board for an Arduino Pro Mini; it provides: 4 | - 8 x relay outputs (10A NO/NC outputs) + LED per channel 5 | - 8 x optically isolated inputs 6 | - 4 x pushbuttons 7 | - 4 x 9-segment LED display (88:88), handy for time/state info 8 | 9 | This example program builds on IO22D08Timers.ino and replaces one of the 10 | digital inputs with a frequency signal; switching RELAY1 based on that input 11 | frequency. 12 | - the external input IN1 switches the R1 relay on/off at an input frequency 13 | of 20Hz (with some hysteresis) 14 | - inputs IN1 and IN2 are connected to the two external interrupt pins on the 15 | Mini and are readily adapted to monitoring a frequency input 16 | - the buttons K1-K4, other inputs IN2-IN8, and display are the same as for 17 | IO22D08Timers 18 | 19 | In addition, a "test mode" is provided that runs an alternate loop() if the 20 | K1 button is held down in setup() when powering on. The test mode cycles various 21 | values through the display and toggles the relay enable control. 22 | 23 | */ 24 | 25 | #include "IO22_IO_Board.h" 26 | 27 | #include 28 | using namespace ace_button; 29 | 30 | IO22D08 io22d08; // create an instance of the relay board 31 | 32 | // AceButton is used to handle both the buttons K1-K4 and the inputs IN2-IN8 33 | // - relays (or timers) are switched via button handler callbacks 34 | ButtonConfig buttonConfig; 35 | AceButton buttons[io22d08.numButtons]; 36 | 37 | ButtonConfig inputConfig; 38 | AceButton inputs[io22d08.numInputs]; 39 | 40 | 41 | // simple timer controlling one or more relays that turns them on with start() 42 | // and, when the elapsed time exceeds the given timeout, off via tick() 43 | // - relayMasks are used to allow multiple relays to be switched together 44 | class RelayTimer 45 | { 46 | protected: 47 | uint32_t _previousMillis = 0; 48 | uint8_t _relayMask = 0; 49 | uint16_t _timeout = 1000UL; 50 | bool _isActive = false; 51 | 52 | public: 53 | RelayTimer() {} 54 | 55 | void setTimeout(uint8_t rm, uint16_t t) 56 | { 57 | _relayMask = rm; 58 | _timeout = t * 1000UL; 59 | } 60 | 61 | void start() 62 | { 63 | Serial.print(F("T(")); 64 | Serial.print(_relayMask); 65 | Serial.println(F("):ON")); 66 | _previousMillis = millis(); 67 | _isActive = true; 68 | // if _relayMask is 0, will be a no-op 69 | io22d08.relaySet(_relayMask, io22d08.RELAY_ON); 70 | } 71 | 72 | void stop() 73 | { 74 | Serial.print(F("T(")); 75 | Serial.print(_relayMask); 76 | Serial.println(F("):OFF")); 77 | _isActive = false; 78 | io22d08.relaySet(_relayMask, io22d08.RELAY_OFF); 79 | } 80 | 81 | void tick() 82 | { 83 | if (_isActive) 84 | if (millis() - _previousMillis > _timeout) stop(); 85 | } 86 | 87 | uint16_t getTimeRemaining() 88 | { 89 | if (_isActive) 90 | return _timeout - (millis() - _previousMillis); 91 | return -1; // maxtime 92 | } 93 | }; 94 | 95 | const size_t numRelayTimers = io22d08.numRelays; // for this demo, as many timers as relays 96 | RelayTimer relayTimers[numRelayTimers]; 97 | 98 | // handler for the onboard buttons K1-K4 99 | // - in this example a short-press starts the timer for relays 1-4 and a 100 | // long-press for relays 5-8 101 | // - the link between buttons and relays (the button ID) is established via the 102 | // AceButton's init 103 | void buttonHandler(AceButton* button, uint8_t eventType, uint8_t /*buttonState*/) 104 | { 105 | uint8_t buttonId = button->getId(); 106 | 107 | Serial.print(F("B")); 108 | Serial.print(buttonId); 109 | Serial.print(F(":")); 110 | Serial.println(AceButton::eventName(eventType)); 111 | 112 | switch (eventType) { 113 | case AceButton::kEventLongPressed: 114 | buttonId += 4; // "virtual" buttons 8-5 115 | [[fallthrough]]; 116 | case AceButton::kEventClicked: 117 | // buttonIDs are not necessarily aligned with timer indices 118 | relayTimers[(buttonId-1)%numRelayTimers].start(); // button N => timer N-1 119 | break; 120 | } 121 | } 122 | 123 | // handler for the digital inputs 124 | // - in this example, simply switch the corresponding relay directly on/off, 125 | // on short-press/release; start a timer on "double-click" 126 | // - the link between inputs and relays (the button ID) is established via the 127 | // AceButton's init 128 | void inputHandler(AceButton* button, uint8_t eventType, uint8_t /*buttonState*/) 129 | { 130 | uint8_t inputNum = button->getId(); 131 | Serial.print(F("I")); 132 | Serial.print(inputNum); 133 | Serial.print(F(":")); 134 | Serial.println(AceButton::eventName(eventType)); 135 | 136 | switch (eventType) { 137 | case AceButton::kEventPressed: 138 | io22d08.relaySetN(inputNum, true); 139 | break; 140 | case AceButton::kEventReleased: 141 | io22d08.relaySetN(inputNum, false); 142 | break; 143 | case AceButton::kEventClicked: 144 | io22d08.relaySetN(inputNum, !io22d08.relayIsOn(inputNum)); 145 | break; 146 | case AceButton::kEventDoubleClicked: 147 | // buttonIDs are not necessarily aligned with timer indices 148 | relayTimers[(inputNum-1)%numRelayTimers].start(); // input N => timer N-1 149 | break; 150 | } 151 | } 152 | 153 | 154 | // frequency controlled switch for IN1 and/or IN2 155 | // - IN1,2 inputs are connected to the two external interrupt pins on the Mini 156 | // and thus can readily monitor a frequency input 157 | // - the other inputs could as well, via a pin change interrupt, but that's 158 | // a lot more work (and will be slower) 159 | // 160 | // - can measure frequency either by 161 | // 1) counting the number of pulses in a given period 162 | // - requires longer sample periods as the frequency decreases (or 163 | // conversely loses resolution) 164 | // 2) measuring the period (e.g. time between rising edges) 165 | // - loses resolution as the frequency increases 166 | // - lower noise-immunity / greater impact from spurious pulses 167 | // 168 | // - for this example the expected input frequency range of ~1-120Hz 169 | // (period: 100-8ms), with a desired 500ms update rate 170 | // - either frequency measurement approach is viable / should work 171 | // - at the lower frequency end the resolution of the pulse-counting approach 172 | // will be relatively poor (i.e. only ~5 pulses per 500ms, so a 20% margin 173 | // of error) so go with the period measuring approach 174 | // - with a 500ms update rate there's budget to apply a filter to reduce noise 175 | // on the frequency measurement; a simple moving average filter works well 176 | // - https://en.wikipedia.org/wiki/Exponential_smoothing 177 | // - expressed as the following, where u is the new sample, and x is the 178 | // smoothed average: 179 | // x = (1-alpha).x + (alpha).u 180 | // - with alpha = 1/(2^n), this expression can be implemented via efficient 181 | // add/subtract and bit shifts (i.e. 1/2^n = >>n) 182 | // - e.g. https://electronics.stackexchange.com/a/34426/264328 183 | // - https://forum.arduino.cc/t/implementing-exponential-moving-average-filter/428637/11 184 | // - at very low frequencies and modest update rates sampling starts 185 | // becoming a problem (i.e. when the incoming pulse train is at 2Hz, you 186 | // can't get an update faster than 2Hz); one approach would be to use a 187 | // multiplier PLL to construct a finer-grained representation of the input 188 | // 189 | // - with the interrupt-based approach to measuring the input signal, if the 190 | // input signal is disconnected then the ISR won't be called and the frequency 191 | // measurement will stop being updated, it'll stay 'stuck' at the last reading 192 | // - if this is a problem for the application, it's easy to deal with via a 193 | // periodic check outside of the ISR: if the last update was longer than 194 | // some expected period, then zero it; this logic should also be reflected 195 | // in the ISR averaging code as well - see PERIOD_MAX below 196 | // - note as the input signal slows right down to DC, the above logic starts 197 | // interfering with the real measurements (i.e. Q: when does "low 198 | // frequency" become "stopped"? A: beyond PERIOD_MAX) 199 | // - some hysteresis is required to prevent chatter 200 | 201 | class FreqSwitch 202 | { 203 | public: 204 | enum FSState {FS_STOPPED, FS_LOW, FS_HIGH}; // Arduino LOW/HIGH be stompin' 205 | 206 | private: 207 | // all values in this class are periods (intervals), in us, not frequencies 208 | 209 | volatile unsigned long _previousMicros; 210 | 211 | volatile unsigned long _period; // last interval 212 | volatile unsigned long _stopped; // longer than this == stopped 213 | volatile unsigned long _lower, _upper; // lower, upper hysteresis thresholds 214 | 215 | const size_t _filterN = 2; // filter alpha = 1/1^n 216 | 217 | FSState _state = FS_STOPPED; // initial state 218 | 219 | public: 220 | // anything longer than this is considered DC (stopped); making this too 221 | // long will slow the filter response once the signal starts up again 222 | const unsigned long PERIOD_MAX = 500*1000UL; // 500ms = 2Hz 223 | 224 | FreqSwitch() {} 225 | 226 | void setThresholds(unsigned long stopped, unsigned long lower, unsigned long upper) 227 | { 228 | _stopped = stopped; 229 | _lower = lower; 230 | _upper = upper; 231 | } 232 | 233 | unsigned long getPeriod() { return _period; } 234 | FSState getState() { return _state; } 235 | 236 | void sample() // this is called by the ISR; keep it short 237 | { 238 | // as the signal approaches DC this function will be called at increasing 239 | // intervals; if the signal is removed it won't get called at all; and thus 240 | // the period won't get updated 241 | static unsigned long currentMicros; 242 | static unsigned long periodN; // period left-shifted (for averaging) 243 | currentMicros = micros(); 244 | // have to do the filtering here to ensure we catch 'em all 245 | // - see above re. this moving average calculation 246 | periodN += (currentMicros - _previousMicros) - _period; 247 | _period = periodN >> _filterN; 248 | _previousMicros = currentMicros; 249 | } 250 | 251 | int tick() 252 | { 253 | // high period == low speed and vice-versa; a high enough period == stopped 254 | 255 | // LOW-HIGH hysteresis: 256 | // transition state => FS_HIGH when period is now lower than lower threshold 257 | // transition state => FS_LOW when period is now higher than upper threshold 258 | // no change in between 259 | switch (_state) 260 | { 261 | case FS_STOPPED: 262 | if (_period < _stopped) _state = _period > _upper ? FS_LOW : FS_HIGH; 263 | break; 264 | case FS_LOW: 265 | if (_period < _lower) _state = FS_HIGH; 266 | if (_period > _stopped) _state = FS_STOPPED; 267 | break; 268 | case FS_HIGH: 269 | if (_period > _upper) _state = FS_LOW; 270 | if (_period > _stopped) _state = FS_STOPPED; 271 | break; 272 | } 273 | 274 | // if the ISR hasn't been called "recently" (longer than the _stopped period), 275 | // force to stopped state 276 | // - this overrides the above FSM 277 | if (micros() - _previousMicros > _stopped) 278 | { 279 | _state = FS_STOPPED; 280 | // it might be tempting to force _period to the max here to "help" the 281 | // sample() filter, but that will be problematic as this function and the 282 | // ISR are totally asynchronous, manipulating any of the filter components 283 | // here will give rise to aliasing effects 284 | } 285 | 286 | return _state; 287 | } 288 | }; 289 | 290 | // - it's easiest to use separate ISRs for each external interrupt 291 | // - an ISR has limitations on its context (e.g. if part of the class, has to 292 | // be a static member function: one and only one); if there is only one ISR 293 | // shared between multiple instances you then have to determine which pin 294 | // triggered the interrupt; you can try and mess around with reading and 295 | // keeping track of the states of each pin to figure that out, but there 296 | // are complexities including race conditions with that approach 297 | FreqSwitch freqSwitch; 298 | void _isr_freq() { freqSwitch.sample(); } 299 | 300 | void (*loop_fn)() = loop_main; // allow switching between main and testmode 301 | 302 | void setup() { 303 | Serial.begin(9600); 304 | io22d08.begin(); 305 | io22d08.displayMessage(io22d08.MESSAGE_BLANK); // clear the display 306 | io22d08.enableRelays(); 307 | 308 | Serial.println(F("\nIO22D08")); 309 | 310 | Serial.print(F("init buttons: K1-K4 ")); 311 | buttonConfig.setEventHandler(buttonHandler); 312 | buttonConfig.setFeature(ButtonConfig::kFeatureClick); 313 | buttonConfig.setFeature(ButtonConfig::kFeatureLongPress); 314 | buttonConfig.setClickDelay(300); 315 | for (size_t b = 0; b < io22d08.numButtons; b++) 316 | { 317 | // button numbers/IDs count from 1, and are normally high (active low) 318 | buttons[b].init(&buttonConfig, io22d08.buttonPins[b], HIGH, b+1); 319 | Serial.print(b+1); 320 | } 321 | Serial.println(F("✔️")); 322 | 323 | Serial.print(F("init frequency input: IN1 (RELAY1) 15,20Hz ")); 324 | pinMode(io22d08.inputPins[0], INPUT); 325 | attachInterrupt(digitalPinToInterrupt(io22d08.inputPins[0]), _isr_freq, RISING); 326 | // thresholds are periods in ms, so inverted: 327 | // - 2Hz = 500ms, 20Hz = 50ms, 15Hz = 66ms 328 | freqSwitch.setThresholds(500e3, 50e3, 66e3); 329 | Serial.println(F("✔️")); 330 | 331 | Serial.print(F("init digital inputs: IN2-IN8 ")); 332 | // the following provides: 333 | // - single pulse = relay pulse (stretched to accommodate double-click) 334 | // - long pulse = relay pulse (i.e. on for as long as input is active) 335 | // - double-pulse = start the timer 336 | // - long-press = turn on the relay, leave it on 337 | inputConfig.setEventHandler(inputHandler); 338 | inputConfig.setFeature(ButtonConfig::kFeatureClick); 339 | inputConfig.setFeature(ButtonConfig::kFeatureLongPress); 340 | inputConfig.setFeature(ButtonConfig::kFeatureDoubleClick); 341 | inputConfig.setFeature(ButtonConfig::kFeatureSuppressAll); 342 | for (size_t i = 1; i < io22d08.numInputs; i++) // start at 1: excl. IN1 343 | { 344 | // button numbers/IDs count from 1, and are normally-high (active low) 345 | inputs[i].init(&inputConfig, io22d08.inputPins[i], HIGH, i+1); 346 | Serial.print(i+1); 347 | } 348 | Serial.println(F("✔️")); 349 | 350 | // go into test mode if K1 is held during boot 351 | if (buttons[0].isPressedRaw()) 352 | { 353 | Serial.println(F("entering testmode")); 354 | // some test timers 355 | // - relays 1-4 on for 4s; 5-8 on for 8s 356 | // - note the testmode loop will cycle the relay enables as well 357 | uint8_t relayMask; 358 | relayMask = io22d08.RELAY1+io22d08.RELAY2+io22d08.RELAY3+io22d08.RELAY4; 359 | relayTimers[0].setTimeout(relayMask, 4); 360 | relayMask = io22d08.RELAY5+io22d08.RELAY6+io22d08.RELAY7+io22d08.RELAY8; 361 | relayTimers[1].setTimeout(relayMask, 8); 362 | // start the timers, once (then handover to "manual" control via buttons) 363 | for (auto & t : relayTimers) t.start(); 364 | 365 | loop_fn = loop_testmode; 366 | return; 367 | } 368 | 369 | // set some demo timer values 370 | // update numRelayTimers to reflect the number of timers being used 371 | Serial.print(F("set relay timers: ")); 372 | size_t i = 0; 373 | relayTimers[i++].setTimeout(io22d08.RELAY1, 4); 374 | relayTimers[i++].setTimeout(io22d08.RELAY2, 6); 375 | relayTimers[i++].setTimeout(io22d08.RELAY3, 8); 376 | relayTimers[i++].setTimeout(io22d08.RELAY4, 10); 377 | relayTimers[i++].setTimeout(io22d08.RELAY5, 12); 378 | relayTimers[i++].setTimeout(io22d08.RELAY6, 16); 379 | relayTimers[i++].setTimeout(io22d08.RELAY7, 20); 380 | relayTimers[i++].setTimeout(io22d08.RELAY8, 30); 381 | Serial.print(i); 382 | Serial.print("/"); 383 | Serial.print(numRelayTimers); 384 | Serial.println(F("✔️")); 385 | 386 | pinMode(A4, OUTPUT); // loop() interval measurement 387 | } 388 | 389 | 390 | // testmode 391 | // - check display is working by cycling through some display values 392 | // - also enable/disable the relays to test the relay enable output 393 | 394 | // don't left-pad these constants (i.e. not octal) 395 | const uint16_t testmodeNumbers[] = {0, 1234, 8, 80, 800, 8000, 8888}; 396 | const size_t numTestmodeNumbers (sizeof(testmodeNumbers)/sizeof(testmodeNumbers[0])); 397 | 398 | void loop_testmode() 399 | { 400 | static unsigned long previousMillis = 0; 401 | unsigned long currentMillis; 402 | currentMillis = millis(); 403 | 404 | if (currentMillis - previousMillis > 1000) 405 | { 406 | previousMillis = currentMillis; 407 | uint8_t i = (currentMillis/1000UL) % numTestmodeNumbers; 408 | io22d08.displayNumber(testmodeNumbers[i]); 409 | io22d08.setColon(i%2 ? false: true); 410 | 411 | // disable the relays for the 0'th display period 412 | if (i) 413 | { 414 | io22d08.enableRelays(); 415 | } 416 | else 417 | { 418 | io22d08.disableRelays(); 419 | } 420 | } 421 | 422 | for (auto & b : buttons) b.check(); 423 | for (auto & i : inputs) i.check(); 424 | for (auto & t : relayTimers) t.tick(); 425 | 426 | // unlike the display, the relay outputs are not multiplexed and don't need 427 | // continual refreshing; however we want to show the display as well 428 | io22d08.refreshDisplayAndRelays(); 429 | } 430 | 431 | 432 | uint16_t _getMinTimeRemaining(uint16_t mtr, RelayTimer* timers, size_t nTimers) 433 | { 434 | for (size_t n = 0; n < nTimers; n++) 435 | { 436 | uint16_t tr = timers[n].getTimeRemaining(); 437 | if (tr < mtr) mtr = tr; 438 | } 439 | return mtr; 440 | } 441 | 442 | void loop_main() 443 | { 444 | static unsigned long previousMillis[] = {0, 0, 0}; 445 | const uint8_t PREVIOUS_MILLIS_COLON = 0; // colon update 446 | const uint8_t PREVIOUS_MILLIS_DISPLAY = 1; // display update 447 | const uint8_t PREVIOUS_MILLIS_FREQ = 2; // freq switch update 448 | unsigned long currentMillis; 449 | currentMillis = millis(); 450 | 451 | // colon flash is asynchronous to timer and display updates 452 | if (currentMillis - previousMillis[PREVIOUS_MILLIS_COLON] > 500) 453 | { 454 | previousMillis[PREVIOUS_MILLIS_COLON] = currentMillis; 455 | io22d08.toggleColon(); 456 | } 457 | 458 | // update display 459 | if (currentMillis - previousMillis[PREVIOUS_MILLIS_DISPLAY] > 250) 460 | { 461 | previousMillis[PREVIOUS_MILLIS_DISPLAY] = currentMillis; 462 | // display the (active) timer that is expiring next (i.e. lowest delta) 463 | io22d08.displayMessage(io22d08.MESSAGE_BLANK); // clear the display 464 | uint16_t mtr = UINT16_MAX; 465 | mtr = _getMinTimeRemaining(mtr, relayTimers, numRelayTimers); 466 | if (mtr != UINT16_MAX) 467 | { 468 | io22d08.displayNumber(mtr/1000UL + 1); // +1: crude ceil() 469 | } 470 | } 471 | 472 | // process frequency switch trigger 473 | if (currentMillis - previousMillis[PREVIOUS_MILLIS_FREQ] > 500) 474 | { 475 | previousMillis[PREVIOUS_MILLIS_FREQ] = currentMillis; 476 | 477 | // - this tick could be executed every cycle but we don't need that fast a 478 | // response 479 | // - freq switch state: 480 | // - LOW: input signal period < low threshold (i.e. f = high) 481 | // - HIGH: input signal period > high threshold (i.e. f = low) 482 | // - relay state (note; this is a binary mask, not a simple two-state var) 483 | // - io22d08.RELAY_OFF: off 484 | // - !io22d08.RELAY_OFF: on 485 | 486 | // report current frequency measurement, but only when it changes 487 | static unsigned long previousPeriod; 488 | long deltaPeriod; 489 | deltaPeriod = freqSwitch.getPeriod() - previousPeriod; 490 | previousPeriod = freqSwitch.getPeriod(); 491 | if (abs(deltaPeriod) > 100) 492 | { 493 | Serial.print(F("f=")); 494 | Serial.print(1e6/previousPeriod); 495 | Serial.print(F("Hz(")); 496 | Serial.print(previousPeriod/1000.0); 497 | Serial.println(F("ms)")); 498 | } 499 | 500 | int freqSwitchState = freqSwitch.tick(); 501 | int relayState = io22d08.relayGet(io22d08.RELAY1); 502 | // in this example, want the relay on at low frequencies, or when stopped 503 | // if the relay is on and needs to be off, turn it off; ditto the inverse 504 | switch (freqSwitchState) 505 | { 506 | case freqSwitch.FS_STOPPED: 507 | [[fallthrough]]; 508 | case freqSwitch.FS_LOW: 509 | if (relayState == io22d08.RELAY_OFF) 510 | { 511 | // should be on but is off, turn on 512 | Serial.println(F("F1<:ON")); 513 | io22d08.relaySet(io22d08.RELAY1, io22d08.RELAY_ON); 514 | } 515 | break; 516 | 517 | case freqSwitch.FS_HIGH: 518 | if (relayState != io22d08.RELAY_OFF) 519 | { 520 | // should be off but is on, turn off 521 | Serial.println(F("F1>:OFF")); 522 | io22d08.relaySet(io22d08.RELAY1, io22d08.RELAY_OFF); 523 | } 524 | break; 525 | } 526 | } 527 | 528 | for (auto & b : buttons) b.check(); 529 | for (auto & i : inputs) i.check(); 530 | for (auto & t : relayTimers) t.tick(); 531 | 532 | io22d08.refreshDisplayAndRelays(); 533 | } 534 | 535 | void loop() { 536 | loop_fn(); 537 | digitalWrite(A4, digitalRead(A4)==HIGH?LOW:HIGH); // to measure loop time 538 | } 539 | --------------------------------------------------------------------------------