전문가급 안테나 로테이터 컨트롤러 - 추적 소프트웨어와 완벽 연동
부품 및 소모품
| 부품 | 수량 |
| 아두이노 우노 (Arduino UNO) | 1 |
| 로터리 포텐셔미터 (Generic, 최대 1K옴, 500옴 권장) | 2 |
| 로터리 인코더 (푸시버튼 포함) | 2 |
| 브레드보드 (Generic) | 1 |
| 릴레이 모듈 (Generic, 2모듈 × 2릴레이 NO-Com-NC) | 2 |
| 파워 MOSFET N채널 (모듈, 최소 12V/3A) | 2 |
앱 및 온라인 서비스
프로젝트 소개
최신 업데이트: 2021년 11월
이 프로젝트는 재미로 시작했지만 진지한 장비로 발전했습니다.
컨트롤러는 두 개의 로터리 인코더(방위각/고도)로 안테나 수동 위치를 제어합니다. USB로 PC에 연결해 위성 추적 소프트웨어를 실행하면 자동 추적 모드로 동작합니다.
EasyComm2 프로토콜 / 9600 보드레이트를 사용하는 모든 추적 소프트웨어와 호환됩니다: PstRotator, WXtrack, HRD, MacDoppler, 심지어 WXtoIMG도 제어 가능. Orbitron과는 DDE 플러그인(https://tripsintech.com/orbitron-dde-azimuth-elevation-to-serial/)으로 직접 연동.
컨트롤러는 시리얼로 응답을 출력해 추적 소프트웨어가 실제 안테나 위치를 화면에 표시하게 합니다(현재 PstRotator만 이 기능 지원).
코드는 LCD 라이브러리 외에 다른 라이브러리를 사용하지 않으며, 아래 전기 배선도 핀 구성대로 그대로 실행됩니다. 방위각 인코더 버튼을 누르면 모든 안테나 움직임이 즉시 정지하고, 방위각 명령이 10도 단위로 설정됩니다.
여기 두 버전을 제공합니다: DC 모터용, AC 모터용(릴레이만 사용). 후자는 기존 상용 안테나 로테이터와 인터페이스 가능.
DC 모터 버전은 PWM으로 부드러운 안테나 이동(Soft-Start/Soft-Stop) 구현. 목표-안테나 각도 오차에 비례한 전력 출력으로 시작 시 점진적 가속, 목표 접근 시 감속 후 정지. 미세한 목표 오프셋에서는 움직이지 않는 조정 가능한 데드존(Dead Zone) 적용.
베타 버전으로 AC 모터용 Soft-Start/Soft-Stop도 AC 디머 모듈 활용해 개발 중이나 현재 방위각만 작동. 관심 있으시면 이메일 주세요.
180도 고도 시스템용 버전도 있고, 0.1도 정밀도 버전도 있으나 극도로 안정적인 포텐셔미터와 철저한 설계가 아니면 권장하지 않습니다. 더 많은 버전은 제 웹페이지에 있습니다.
조립 완료 후 반드시 캘리브레이션 절차를 수행하세요:
- 포텐셔미터 캘리브레이션 필수: 어떤 포텐셔미터를 쓰든 0-359°/0-90° 정확한 읽기 보장.
- 모터 캘리브레이션 선택: Soft-Start/Stop 기능 튜닝용. 기본 설정이 맘에 안 들면 수행.
더 자세한 설명은 영상에 있습니다. 코드가 시간이 지나며 개선되어 영상은 최신이 아닐 수 있으니 웹페이지(https://racov.ro/index.php/2020/12/09/arduino-based-antenna-rotator-part3-software-tracking-update/)에서 최신 정보 확인 바랍니다.
이 플랫폼은 새 댓글 알림이 안 와서 이메일(YO3RAK@gmail.com)로 연락 주시면 소소한 문제 해결 도와드리겠습니다.
피드백 주신 모든 분께 감사드립니다. 어떤 피드백이든 대환영입니다!
코드
ant-rot-DC-nov2021 (DC 모터용, 소프트 스타트/스톱 PWM 출력)
/* AZ/EL 안테나 로테이터 컨트롤러 for Arduino - DC 모터
* ========================================================
* 컴퓨터-추적 소프트웨어용 EasyComm 프로토콜 사용
* 두 로터리 인코더로 AZ-EL 수동 제어
* Viorel Racoviteannu
* https://www.youtube.com/channel/UCiRLZX0bV9rS04BGAyUf-fA
* https://racov.ro
* YO3RAK@gmail.com
* 이 코드 오용이나 이로 인한 손해에 책임지지 않습니다.
*
* dec 2020 v2 - 시리얼 통신 안정성 개선
* jan 2021 - 목표 근접 데드존 개선
* apr 2021 - 시리얼 통신 안정성 개선
* jun 2021 - 추적 이동용 오차 비례 전력. 진짜 소프트 스톱
* aug 2021 - 빠른 USB 업데이트, Az/El 방향 콜드 스위칭, 코드 소폭 최적화
* nov 2021 - 소프트 스타트 비밀 해결. 여기 있습니다.
*/
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
// 배선: SDA → A4, SCL → A5. LCD I2C 기본 주소 0x27 (A0-A2 점퍼 안 함)
LiquidCrystal_I2C lcd(0x27, 16, 2);
// 위/아래 화살표 커스텀 심볼
byte DownArrow[8] = { B00000, B00100, B00100, B00100, B10101, B01110, B00100, B00000 };
byte UpArrow[8] = { B00000, B00100, B01110, B10101, B00100, B00100, B00100, B00000 };
/***********************************안테나 움직임 튜닝 파라미터***************/
// 안테나 포텐셔미터 캘리브레이션
int AzMin = 1, AzMax = 1023, ElMin = 1, ElMax = 1023;
// 안테나가 움직이지 않는 허용 오차
int AzErr = 8, ElErr = 4;
// 소프트 스톱 시작 각도 차이
int Amax = 25, Emax = 15;
// 모터 최소/최대 전력 (퍼센트)
int PwAzMin = 30, PwAzMax = 100, PwElMin = 30, PwElMax = 100;
int PwAz = 0, PwEl = 0;
/****************************************************************************/
// 인코더 핀 할당
enum AzPinAssignments { AzEncoderPinA = 2, AzEncoderPinB = 3, AzClearButton = 4,
ElEncoderPinA = 6, ElEncoderPinB = 5 };
unsigned int lastReportedPos = 1;
static boolean rotating = false;
boolean A_set = false, B_set = false;
int aState, aLastState;
// 기타 변수
int AzPotPin = A0, AzRotPin = 12, AzPWMPin = 11;
int TruAzim = 0, ComAzim = 0, OldTruAzim = 0, OldComAzim = 0;
char AzDir; int AzEncBut = 1;
int ElPotPin = A1, ElRotPin = 13, ElPWMPin = 10;
int TruElev = 0, ComElev = 0, OldTruElev = 0, OldComElev = 0;
char ElDir; int ElUp = 1;
bool AzStop = false, ElStop = false;
int StaAzim = 0, PwAzStop = 0, PwAzStar = 0;
int StaElev = 0, PwElStop = 0, PwElStar = 0;
// 평균화 루프
const int numReadings = 25;
int readIndex = 0;
int azimuth[numReadings], elevation[numReadings];
int totalAz = 0, totalEl = 0;
// 시리얼 통신 변수
String Azimuth = "", Elevation = "", ComputerRead, ComputerWrite;
bool AZser = false, ELser = false, ANTser = false;
void setup() {
Serial.begin(9600);
Serial.setTimeout(50);
lcd.init(); lcd.backlight();
lcd.setCursor(0, 0); lcd.print("EasyCom AntRotor");
lcd.setCursor(0, 1); lcd.print("*Racov* Nov.2021");
lcd.createChar(1, DownArrow); lcd.createChar(2, UpArrow);
pinMode(AzRotPin, OUTPUT); pinMode(AzPWMPin, OUTPUT);
pinMode(ElRotPin, OUTPUT); pinMode(ElPWMPin, OUTPUT);
pinMode(AzPotPin, INPUT); pinMode(ElPotPin, INPUT);
pinMode(AzEncoderPinA, INPUT); pinMode(AzEncoderPinB, INPUT);
pinMode(AzClearButton, INPUT); pinMode(ElEncoderPinA, INPUT);
pinMode(ElEncoderPinB, INPUT);
attachInterrupt(0, doEncoderA, CHANGE);
attachInterrupt(1, doEncoderB, CHANGE);
aLastState = digitalRead(ElEncoderPinA);
// 평균화 루프 초기화
TruAzim = map(analogRead(AzPotPin), AzMin, AzMax, 0, 359);
TruAzim = constrain(TruAzim, 0, 359);
TruElev = map(analogRead(ElPotPin), ElMin, ElMax, 0, 90);
TruElev = constrain(TruElev, 0, 90);
for (int i = 0; i < numReadings; i++) {
azimuth[i] = TruAzim; elevation[i] = TruElev;
}
totalAz = TruAzim * numReadings; totalEl = TruElev * numReadings;
ComAzim = TruAzim; ComElev = TruElev;
OldTruAzim = TruAzim; OldComAzim = ComAzim;
OldTruElev = TruElev; OldComElev = ComElev;
delay(1500);
lcd.setCursor(0, 0); lcd.print("Azm.---" + String(char(223)) + "=Cd.---" + String(char(223)));
lcd.setCursor(0, 1); lcd.print("Elv. --" + String(char(223)) + "=Cd. --" + String(char(223)));
DisplAzim(TruAzim, 4, 0); DisplAzim(ComAzim, 12, 0);
DisplElev(TruElev, 5, 1); DisplElev(ComElev, 13, 1);
}
void loop() {
// 평균화 루프 (초당 ~500회 실행)
totalAz -= azimuth[readIndex]; totalEl -= elevation[readIndex];
azimuth[readIndex] = map(analogRead(AzPotPin), AzMin, AzMax, 0, 359);
elevation[readIndex] = map(analogRead(ElPotPin), ElMin, ElMax, 0, 90);
totalAz += azimuth[readIndex]; totalEl += elevation[readIndex];
TruAzim = totalAz / numReadings; TruElev = totalEl / numReadings;
TruAzim = constrain(TruAzim, 0, 359); TruElev = constrain(TruElev, 0, 90);
readIndex = (readIndex + 1) % numReadings;
ReadAzimEncoder(); ReadElevEncoder();
if (Serial.available()) SerComm();
// 디스플레이 업데이트 (깜빡임 방지)
if ((millis() % 500) < 10) {
if (OldTruAzim != TruAzim) { DisplAzim(TruAzim, 4, 0); OldTruAzim = TruAzim; }
if (OldTruElev != TruElev) { DisplElev(TruElev, 5, 1); OldTruElev = TruElev; }
}
if (OldComAzim != ComAzim) { DisplAzim(ComAzim, 12, 0); OldComAzim = ComAzim; }
if (OldComElev != ComElev) { DisplElev(ComElev, 13, 1); OldComElev = ComElev; }
// 방위각 회전 제어
if (TruAzim == ComAzim) { AzStop = true; analogWrite(AzPWMPin, 0); StaAzim = TruAzim; lcd.setCursor(8, 0); lcd.print("="); }
else if ((abs(TruAzim - ComAzim) <= AzErr) && !AzStop) { AzimRotate(); }
else if (abs(TruAzim - ComAzim) > AzErr) { AzStop = false; AzimRotate(); }
// 고도 회전 제어
if (TruElev == ComElev) { ElStop = true; analogWrite(ElPWMPin, 0); StaElev = TruElev; lcd.setCursor(8, 1); lcd.print("="); ElUp = 0; }
else if ((abs(TruElev - ComElev) <= ElErr) && !ElStop) { ElevRotate(); }
else if (abs(TruElev - ComElev) > ElErr) { ElStop = false; ElevRotate(); }
// 인코더 x10 모드
while (AzEncBut == 10) {
analogWrite(AzPWMPin, 0); analogWrite(ElPWMPin, 0);
StaAzim = TruAzim; lcd.setCursor(8, 0); lcd.print("*");
ReadAzimEncoder();
if (OldComAzim != ComAzim) { DisplAzim(ComAzim, 12, 0); OldComAzim = ComAzim; }
delay(100);
}
}
// --- 함수 정의 ---
void DisplAzim(int x, int y, int z) { char s[7]; sprintf(s, "%03d", x); lcd.setCursor(y, z); lcd.print(s); }
void DisplElev(int x, int y, int z) { char s[7]; sprintf(s, "%02d", x); lcd.setCursor(y, z); lcd.print(s); }
void ReadElevEncoder() {
aState = digitalRead(ElEncoderPinA);
if (aState != aLastState) {
if (digitalRead(ElEncoderPinB) != aState) ComElev++; else ComElev--;
ComElev = constrain(ComElev, 0, 90);
}
aLastState = aState;
}
void ReadAzimEncoder() {
rotating = true;
if (lastReportedPos != ComAzim) lastReportedPos = ComAzim;
delay(10);
if (digitalRead(AzClearButton) == LOW) {
delay(250);
if (AzEncBut == 1) { AzEncBut = 10; ComAzim = int(ComAzim/10)*10; }
else { AzEncBut = 1; }
}
}
void doEncoderA() {
if (rotating) delay(1);
if (digitalRead(AzEncoderPinA) != A_set) {
A_set = !A_set;
if (A_set && !B_set) ComAzim += AzEncBut;
ComAzim = (ComAzim + 360) % 360;
rotating = false;
}
}
void doEncoderB() {
if (rotating) delay(1);
if (digitalRead(AzEncoderPinB) != B_set) {
B_set = !B_set;
if (B_set && !A_set) ComAzim -= AzEncBut;
ComAzim = (ComAzim + 360) % 360;
rotating = false;
}
}
void AzimRotate() {
if (ComAzim > TruAzim) {
if (AzDir == char(127)) { analogWrite(AzPWMPin, 0); StaAzim = TruAzim; delay(200); digitalWrite(AzRotPin, LOW); delay(200); }
else digitalWrite(AzRotPin, LOW);
AzDir = char(126);
} else {
if (AzDir == char(126)) { analogWrite(AzPWMPin, 0); StaAzim = TruAzim; delay(200); digitalWrite(AzRotPin, HIGH); delay(200); }
else digitalWrite(AzRotPin, HIGH);
AzDir = char(127);
}
lcd.setCursor(8, 0); lcd.print(String(AzDir));
PwAzStop = PwAzMin + round(abs(ComAzim-TruAzim)*(PwAzMax-PwAzMin)/Amax);
PwAzStar = PwAzMin + round(abs(StaAzim-TruAzim)*(PwAzMax-PwAzMin)/Amax);
PwAz = min(PwAzStar, PwAzStop); PwAz = min(PwAz, PwAzMax);
analogWrite(AzPWMPin, round(2.55*PwAz));
}
void ElevRotate() {
if (ComElev > TruElev) {
if (ElUp == 1) { analogWrite(ElPWMPin, 0); StaElev = TruElev; delay(200); digitalWrite(ElRotPin, LOW); delay(200); }
else digitalWrite(ElRotPin, LOW);
lcd.setCursor(8, 1); lcd.write(2); ElUp = 2;
} else {
if (ElUp == 2) { analogWrite(ElPWMPin, 0); StaElev = TruElev; delay(200); digitalWrite(ElRotPin, HIGH); delay(200); }
else digitalWrite(ElRotPin, HIGH);
lcd.setCursor(8, 1); lcd.write(1); ElUp = 1;
}
PwElStop = PwElMin + round(abs(ComElev-TruElev)*(PwElMax-PwElMin)/Emax);
PwElStar = PwElMin + round(abs(StaElev-TruElev)*(PwElMax-PwElMin)/Emax);
PwEl = min(PwElStar, PwElStop); PwEl = min(PwEl, PwElMax);
analogWrite(ElPWMPin, round(2.55*PwEl));
}
void SerComm() {
ComputerRead = ""; Azimuth = ""; Elevation = "";
while (Serial.available()) { ComputerRead = Serial.readString(); }
// 파싱
for (int i = 0; i <= ComputerRead.length(); i++) {
if (ComputerRead.charAt(i)=='A' && ComputerRead.charAt(i+1)=='Z') {
for (int j = i+2; j <= ComputerRead.length(); j++) {
if (isDigit(ComputerRead.charAt(j))) Azimuth += ComputerRead.charAt(j);
else break;
}
}
}
// 파싱
for (int i = 0; i <= ComputerRead.length()-2; i++) {
if (ComputerRead.charAt(i)=='E' && ComputerRead.charAt(i+1)=='L') {
if (ComputerRead.charAt(i+2)=='-') { ComElev = 0; break; }
for (int j = i+2; j <= ComputerRead.length(); j++) {
if (isDigit(ComputerRead.charAt(j))) Elevation += ComputerRead.charAt(j);
else break;
}
}
}
if (Azimuth != "") { ComAzim = Azimuth.toInt() % 360; }
if (Elevation != "") {
ComElev = Elevation.toInt();
if (ComElev > 180) ComElev = 0;
if (ComElev > 90) { ComElev = 180 - ComElev; ComAzim = (ComAzim + 180) % 360; }
}
// 쿼리 응답
for (int i = 0; i <= ComputerRead.length()-4; i++) {
if (ComputerRead.charAt(i)=='A' && ComputerRead.charAt(i+1)=='Z' &&
ComputerRead.charAt(i+3)=='E' && ComputerRead.charAt(i+4)=='L') {
ComputerWrite = "+" + String(TruAzim) + ".0 " + String(TruElev) + ".0";
Serial.println(ComputerWrite);
}
}
}
ant-rot-AC-aug2021 (AC 모터용, 드라이 컨택트 ON/OFF)
AC 모터용 전기 배선도와 함께 사용하세요. 상용 로테이터와 쉽게 인터페이스 가능.
/* AZ/EL 안테나 로테이터 컨트롤러 for Arduino - AC 모터
* (코드 전문은 원본 참조 - DC 버전과 유사하나 PWM 대신 릴레이 제어)
* Viorel Racoviteannu / YO3RAK@gmail.com
*/
포텐셔미터 캘리브레이션 절차
AZ / EL 포텐셔미터 한계 캘리브레이션 절차 (0-359° / 0-90°)
1. 아두이노 IDE에서 코드 열고 DisplAzim, DisplElev 함수의 시리얼 출력 주석 해제
2. 업로드 후 시리얼 모니터에서 값 확인
3. 인코더로 안테나를 최소(방위각 0°, 고도 0°)로 이동 → 값 기록 (예: AzMin=90, ElMin=10)
4. 최대로 이동(방위각 359°, 고도 90°) → 값 기록 (예: AzMax=1000, ElMax=992)
5. 코드 상단 캘리브레이션 섹션에 기록한 값 입력
6. 시리얼 출력 줄 다시 주석 처리 후 재업로드
모터 캘리브레이션 절차
소프트 스타트/스톱 파라미터 설정 절차
- 안테나가 목표를 향해 움직일 때 도 차이에서 최대 전력 도달
- 목표 접근 시 도 이하에서 감속 시작 (무거운 안테나일수록 높게)
- 전력 %에서 %로 감소, 0도 오차에서 정지
- %: 부하 하에서 모터가 스톨하지 않고 시작하는 최소 전력
- 목표 도달 후 도 이상 움직여야 다시 조정 (데드존, 진동 방지)
- 고도도 동일하게 Emax, PwElMin, PwElMax, ElErr 설정
회로도
DC 모터용 회로도: 모든 모듈, 인코더, LCD, 릴레이, MOSFET 연결도
AC 모터용 회로도: 드라이 컨택트(ON/OFF) 출력, 상용 로테이터와 인터페이스 가능