/* * LM: Convert received serial messages to RCA TV antenna rotator IR codes * Compiled for Heltec WiFi Kit 32(V3) using Arduino IDE 2.2.1 * * Based in part on the HelTec Automation(TM) WIFI_Kit_32 factory test code * by Aaron.Lee from HelTec AutoMation, ChengDu, China * https://github.com/HelTecAutomation/Heltec_ESP32 * * IR code is based on the IRremote IR SimpleSender example. * * Lloyd Milligan (WA4EFS) Oct/Nov 2023 * © CC attribution - https://creativecommons.org/licenses/by/3.0/us/ * * V.1.1 Special handling of 0° crossing * 1.1.1 Revise time allowed for 30° multiple rotation * 1.1.2 Minor change to splash display * 1.1.3 Soft restart (recessed pushbutton) * 1.1.4 Add OLED info messages * Simplify 'smooth' rotations * */ #include "heltec.h" // MIT License #include "PinDefinitionsAndMore.h" #include #define VERSION "1.1.4" const unsigned long JIFFY = 100; const unsigned long HALFSEC = 500; const unsigned long ONESEC = 1000; const unsigned long TWOSEC = 2000; const unsigned long FIVESEC = 5000; const unsigned long GO_DARK_TIME = 120000; // LM: Save screen unsigned long oledDisplayStartTime = millis(); #define DISABLE_CODE_FOR_RECEIVER #define TEST_MODE_PIN 33 #define RESTART_PIN 34 // IR uint8_t rAddress = 0x6; uint8_t rCommand = 0x4; uint8_t rRepeats = 0; // Serial HardwareSerial azSerial(2); // Serial channel that receives azimuths #define RXD 47 #define TXD -1 #define DEFAULT_SER_BAUD 9600 #define AZ_SER_BAUD 9600 const int RX_BUFFER_SIZE = 16; byte azRxBuffer[RX_BUFFER_SIZE]; unsigned int rxBufferNdx = 0; const char CR = (char) 0x0D; const char SP = ' '; const char DOT = '.'; const char ZERO = '0'; boolean irEmitterTest = false; boolean irCodesTest = false; boolean rxBufferTest = false; boolean arrowKeysTest = false; boolean oledTextTest = false; boolean newArrowKeysRepetitionsTest = false; boolean zeroCrossingTest = false; boolean smoothRotateTest = false; // Message parsing / processing const byte ROTATE_CCW = 0x11; const byte ROTATE_CW = 0x12; const unsigned long AZ_REV_TIME = 60000; // Approximate time for one 360° revolution of the antenna (57 seconds) const unsigned long DELAY_PER_REPEAT = AZ_REV_TIME/(17*30); const unsigned long DELAY_PER_ARROW_BUTTON_PRESS = HALFSEC; const unsigned long DELAY_PER_SMOOTH_DEGREE = 200; const int MIN_AZ_DELTA = 5; // Minimum change in azimuth (degrees) that will produce a rotation const float DEG_PER_ARROW_BUTTON_PRESS = .75; // Experimentally determined (40 presses = 30 degrees) const int PRESSES_PER_DEGREE_NUMERATOR = 4; // Reciprocal of DEG_PER_ARROW_BUTTON_PRESS expressed as fraction const int PRESSES_PER_DEGREE_DENOMINATOR = 3; unsigned long azCmdStartTime = 0; unsigned long lastRxTime = 0; // Interface active indicator int lastProcessedAzimuth = -9; // -9 indicates no azimuth has been processed for current satellite byte lastLettercode; // Last 30-degree mark set boolean azRotationEnabled = false; // int rotorInterfaceType = 1; // Enum 0 = Custom Mxxx message // 1 = EASYCOMM-1 format AZx 0x0 ELx // High elevation passes change azimuth by nearly 180° in a short time, // producing too many queued changes to process in the time received. // To deal with this, update periodically even if no change in azimuth. const unsigned long MIN_AZ_UPDATE_INTERVAL = 30000; unsigned long lastAzUpdateTime = 0; // OLED #define OLED_BG BLACK #define OLED_FG WHITE #define OLED_WIDTH 128 #define OLED_HEIGHT 64 const unsigned int LINESPACE = 12; // Minimum vertical line-spacing for text const unsigned int TEXT_ROWS = 5; // Max text rows that fit vertically at default font int rowPointer = 0; // For displaying OLED text in successive rows boolean testMode = false; void setup() { pinMode(LED,OUTPUT); pinMode(TEST_MODE_PIN, INPUT); pinMode(RESTART_PIN, INPUT); digitalWrite(LED,HIGH); delay (ONESEC); // LM: Do not leave illuminated (hard on eyes) digitalWrite(LED, LOW); // Following displays an 'init' message Heltec.begin(true /*DisplayEnable Enable*/, false /*LoRa Enable*/, true /*Serial Enable*/); Heltec.display->clear(); delay(TWOSEC); oledDisplaySplash(); delay(FIVESEC); oledClearScreen(); Serial.begin(DEFAULT_SER_BAUD); azSerial.begin(AZ_SER_BAUD, SERIAL_8N1, RXD, TXD); clearAzSerialInterface(); initRxSerialBuffer(); IrSender.begin(DISABLE_LED_FEEDBACK); // Init infrared emitter (Antenna rotator) processTestFunctions(); if (digitalRead(TEST_MODE_PIN) == LOW) testMode = true; Serial.println(); Serial.println("Setup complete!"); delay(TWOSEC); oledClearScreen(); } void loop() { pollRestartBtn(); if (azSerial.available()) readAzSerial(); if (millis() - oledDisplayStartTime > GO_DARK_TIME) { oledClearScreen(); } } // -----------------------------------Serial----------------------------------- void clearAzSerialInterface() { // Clear serial channel while (azSerial.available()) azSerial.read(); } void initRxSerialBuffer() { // Clear application Rx buffer for (unsigned int i=0; i= 32 && c < 127) { rxBufferNdx = rxBufferNdx % RX_BUFFER_SIZE; Serial.print("["); Serial.print(rxBufferNdx); Serial.print("] -> "); Serial.print((char) c); azRxBuffer[rxBufferNdx++] = c; } Serial.println(); } lastRxTime = millis(); } // -------------------------------IR Interface--------------------------------- byte deg2IR(int iDeg) { // Convert azimuth to neareast 30-deg IR code that is less than iDeg return (iDeg % 360) / 30 + 0x4; } void smoothRotateCW(int smallAngle) { // Rotate clockwise displayRotateCW(smallAngle); byte nRepetitions = (byte) smallAngle; IrSender.sendNEC(rAddress, ROTATE_CW, nRepetitions); } void smoothRotateCCW(int smallAngle) { // Rotate counterclockwise displayRotateCCW(smallAngle); byte nRepetitions = (byte) smallAngle; IrSender.sendNEC(rAddress, ROTATE_CCW, nRepetitions); } int parseAzimuth() { byte b = azRxBuffer[0]; int az; Serial.print("azRxBuffer[0] = "); Serial.print(b, HEX); Serial.print(" (char) "); Serial.println((char) b); if (rotorInterfaceType == 0) { if ((char) b == 'M') { az = 100*(azRxBuffer[1]-ZERO) + 10*(azRxBuffer[2]-ZERO) + (azRxBuffer[3]-ZERO); } else { return -1; // No valid azimuth data } } else if (rotorInterfaceType == 1) { if ((char) b == 'A' && (char) azRxBuffer[1] == 'Z') { if (azRxBuffer[3] == 0 || (char) azRxBuffer[3] == DOT || (char) azRxBuffer[3] == SP) { az = (azRxBuffer[2]-ZERO); } else if (azRxBuffer[4] == 0 || (char) azRxBuffer[4] == DOT || (char) azRxBuffer[4] == SP) { az = 10*(azRxBuffer[2]-ZERO) + (azRxBuffer[3]-ZERO); } else if (azRxBuffer[5] == 0 || (char) azRxBuffer[5] == DOT || (char) azRxBuffer[5] == SP) { az = 100*(azRxBuffer[2]-ZERO) + 10*(azRxBuffer[3]-ZERO) + (azRxBuffer[4]-ZERO); } else { Serial.println("Invalid azimuth!"); return -2; // No valid azimuth data } } else { Serial.println("Invalid value for rotor interface type 1."); return -3; // No valid azimuth data } } else { Serial.println("Unknown rotor interface type."); return -4; // No valid azimuth data } return az; } void recordAzimuthUpdate(int az) { lastProcessedAzimuth = az; lastAzUpdateTime = millis(); } void processRxBuffer() { Serial.println(); Serial.println("In processRxBuffer()"); if (rxBufferTest) { processTestRxBuffer(); return; } int az = parseAzimuth(); if (az < 0) { initRxSerialBuffer(); return; } Serial.print("Target azimuth: "); Serial.print(az); Serial.println(" degrees."); if (lastProcessedAzimuth < 0) initAzimuth(az); else updateAzimuth(az); initRxSerialBuffer(); } int nearest30degMult(int iDeg) { int jDeg = (iDeg % 360)/ 30 * 30; if (iDeg - jDeg > 15 && iDeg < 330) jDeg += 30; return jDeg; } boolean isOtherSideOfZero(int targetAz) { // Does target rotation cross 0° in either direction? if (targetAz < 30 && lastProcessedAzimuth > 300) // Clockwise rotation through 360° return true; if (lastProcessedAzimuth < 30 && targetAz > 300) // Counterclockwise rotation through 0° return true; return false; } boolean updateThresholdMet(int iDeg) { int i = iDeg < 0 ? -iDeg : iDeg; int j = lastProcessedAzimuth - i; j = j < 0 ? -j : j; // Case: Queued updates overflow (near zenith pass situation) if (lastAzUpdateTime > 0 && (millis() - lastAzUpdateTime > MIN_AZ_UPDATE_INTERVAL) && (millis() - lastRxTime < MIN_AZ_UPDATE_INTERVAL)) return true; return j >= MIN_AZ_DELTA; } void rotateToStoredAzimuth(int az, unsigned long waitTime) { // Assumes that az is a multiple of 30 degrees corresponding to // a value pre-stored in controller's letter-labeled memory - // 'A' = 0 degrees, 'b' = 30 degrees, 'c' = 60 degrees, etc. azCmdStartTime = millis(); IrSender.sendNEC(rAddress, deg2IR(az), 0x0); while(millis() - azCmdStartTime < waitTime) delay(1); } void initAzimuth(int iDeg) { azRotationEnabled = true; int jDeg = nearest30degMult(iDeg); displayApproxAzimuth(jDeg); rotateToStoredAzimuth(jDeg, AZ_REV_TIME / 2); recordAzimuthUpdate(jDeg); updateAzimuth(iDeg); } void updateAzimuth(int iDeg) { if (isOtherSideOfZero(iDeg)) { processZeroCrossingRotation(); return; } if (!updateThresholdMet(iDeg) ) { Serial.println("No update required."); return; } int jDeg = nearest30degMult(iDeg); int kDeg = nearest30degMult(lastProcessedAzimuth); int nLtrs; if (jDeg != kDeg) { displayApproxAzimuth(jDeg); // Usually 1 letter difference, *except* after 0-degree crossing nLtrs = (jDeg - kDeg) / 30; if (nLtrs < 0) nLtrs = -nLtrs; rotateToStoredAzimuth(jDeg, nLtrs*FIVESEC); recordAzimuthUpdate(jDeg); } displayTargetAzimuth(iDeg); // Smooth-adjust int deltaAz = iDeg - lastProcessedAzimuth; if (deltaAz > 0) smoothRotateCW(deltaAz); else if (deltaAz < 0) { deltaAz = -deltaAz; smoothRotateCCW(deltaAz); } else return; delay((deltaAz+1) * DELAY_PER_SMOOTH_DEGREE); recordAzimuthUpdate(iDeg); } void processZeroCrossingRotation() { // Rotate to 180° oledDisplayVerticalTicker("Rotating to 180 deg."); IrSender.sendNEC(rAddress, deg2IR(180), 0x0); recordAzimuthUpdate(180); delay(AZ_REV_TIME/2); // Assume starting azimuth is close to 0° initRxSerialBuffer(); // Discard queued update clearAzSerialInterface(); // Wait for next update from sender } void displayTargetAzimuth(int iDeg) { String s = "Target Az = "; s.concat(String(iDeg)); s.concat(" deg."); oledDisplayVerticalTicker(s); } void displayApproxAzimuth(int jDeg) { String s = "Setting to "; s.concat(String(jDeg)); s.concat(" deg."); oledDisplayVerticalTicker(s); } void displayRotateCW(int smDeg) { String s = "Rotating CW "; s.concat(String(smDeg)); s.concat(" deg."); oledDisplayVerticalTicker(s); } void displayRotateCCW(int smDeg) { String s = "Rotating CCW "; s.concat(String(smDeg)); s.concat(" deg."); oledDisplayVerticalTicker(s); } // -------------------------------OLED Utilities------------------------------- void oledEraseRow(int rowNumber) { int16_t y0 = rowNumber*LINESPACE; int16_t y1 = y0 + LINESPACE; Heltec.display->setColor(OLED_BG); Heltec.display->fillRect(0, y0, OLED_WIDTH, y1); Heltec.display -> display(); } void oledDisplayText(int rowNumber, String sTxt) { int16_t y0 = rowNumber*LINESPACE; Heltec.display->setColor(OLED_FG); // Alignment? Font? Heltec.display->drawString(0, y0, sTxt); Heltec.display -> display(); oledDisplayStartTime = millis(); } void oledDisplayText(int rowNumber, int colNumber, String sTxt) { int16_t y0 = rowNumber*LINESPACE; int16_t x0 = colNumber; Heltec.display->setColor(OLED_FG); // Alignment? Font? Heltec.display->drawString(x0, y0, sTxt); Heltec.display -> display(); oledDisplayStartTime = millis(); } void oledDisplayVerticalTicker(String sTxt) { // Display text at rowPointer (current row), increment rowPointer, erase next row. int nextRow = (rowPointer +1) % TEXT_ROWS; oledDisplayStartTime = millis(); oledEraseRow(nextRow); // Erase before draw to protect letter stems oledDisplayText(rowPointer, sTxt); rowPointer = nextRow; } void oledClearScreen() { Heltec.display -> clear(); Heltec.display -> display(); } void oledDisplaySplash() { oledDisplayText(1, 12, "IR Azimuth Interface"); oledDisplayText(2, 50, VERSION); oledDisplayText(3, 34, "W A 4 E F S"); oledDisplayText(4, 8, "https://www.lloydm.net"); } // ------------------------------ Generic Utilities---------------------------- void pollRestartBtn() { // Test recessed pushbutton and process restart if requested if (digitalRead(RESTART_PIN) == HIGH) return; while (digitalRead(RESTART_PIN) == LOW) delay(1); delay(JIFFY); oledDisplayText(2, 10, "Restart requested"); oledDisplayText(3, 10, "Please standby..."); delay(TWOSEC); ESP.restart(); } // ---------------------------------Debug/Test--------------------------------- void testIRemitter() { Serial.println(); Serial.println(F("START " __FILE__ " from " __DATE__ "\r\nUsing library version " VERSION_IRREMOTE)); Serial.print(F("Send IR signals at pin ")); Serial.println(IR_SEND_PIN); Heltec.display -> drawString(0, 4*9, "IR Emitter Test"); Heltec.display -> display(); for (int i=0; i<5; i++) { Serial.print("Sending IR command: "); Serial.print(rCommand, HEX); Serial.print(" to address: "); Serial.println(rAddress, HEX); IrSender.sendNEC(rAddress, rCommand, rRepeats); if (rCommand == 0x4) rCommand = 0x5; else rCommand = 0x4; delay(TWOSEC); } oledClearScreen(); Serial.println(); } void processTestRxBuffer() { for (unsigned int i=0; i drawString(0, 5*9, "RxSerial Test"); Heltec.display -> display(); } if (arrowKeysTest) testArrowKeys(); if (oledTextTest) { oledClearScreen(); testDisplayEraseText(); } if (newArrowKeysRepetitionsTest) { oledClearScreen(); newArrowKeysTest(10); } if (zeroCrossingTest) simulateCounterclockwiseZeroCrossing(); if (smoothRotateTest) testSmoothRotateFunctions(); } // Heltec OLED display functions not included in factory test //https://www.sabulo.com/sb/esp32-development-board/how-to-use-the-heltec-oled-display-on-the-esp32/ void fillRect(void) { uint8_t color = 1; for (int16_t i = 0; i < DISPLAY_HEIGHT / 2; i += 3) { // alternate colors Heltec.display->setColor((color % 2 == 0) ? BLACK : WHITE); Heltec.display->fillRect(i, i, DISPLAY_WIDTH - i * 2, DISPLAY_HEIGHT - i * 2); Heltec.display->display(); delay(10); color++; } // Reset back to WHITE Heltec.display->setColor(WHITE); }