בסוף הצעד: יש תכנית RX חוקית ובטוחה
קראו את תיבת הבטיחות ובחרו מטרה: קודם בונים מקלט עובד. אל תחברו אנטנה ואל תשנו את נעילת TX.
מכשיר קשר UHF אמיתי בגודל כיס: חוגה לתדרים, מסך RSSI, שמע אנלוגי ו־2W — נבנה קודם כמקלט בטוח, עם שער TX מפורש לחובבים מורשים.
חינם · 15 צעדים· 8 שעות
פרויקט מתקדם ומעשי סביב SA868S‑UHF ו־XIAO ESP32‑C3. בונים מערכת מלאה, בודקים כל תת־מערכת בנפרד ומסיימים במדידת טווח אמיתית. היצרן מציין עד כ־5 ק״מ בשטח פתוח; בפועל הטווח תלוי באנטנה, קו ראייה וההספק החוקי.
הכנה לבנייה
בחרו בדיוק את הדגמים והמפרטים שמופיעים כאן. קישורי החנויות הם חיפושים יציבים — לפני הזמנה משווים תמונות, גרסת UHF, מתח ודירוג זרם.
1 אוספים ומסמנים את החלקים
קראו את תיבת הבטיחות ובחרו מטרה: קודם בונים מקלט עובד. אל תחברו אנטנה ואל תשנו את נעילת TX.
הניחו את SA868S מול סימון pin 1. סמנו בטוש/מדבקה את 3 AF_OUT, 5 PTT, 6 PD, 7 H/L, 8 VBAT, 9/10 GND, 12 ANT, 16 RXD, 17 TXD ו־18 MIC_IN.
חברו LiPo מוגנת → פיוז 1.5A → מפסק → VBAT של SA868S. חברו 1000µF במקביל קרוב למודול (שימו לב לקוטביות) ו־100nF לצדו. ה־XIAO מתחבר לאותה סוללה דרך פדי BAT שלו לפי הוראות Seeed.
לפני חיבור הרדיו, חברו OLED: VCC ל־3V3, GND, SDA ל־D4 ו־SCL ל־D5. העלו דוגמת SSD1306 וסרקו 0x3C אם המסך ריק.
D6 TX של XIAO אל pin 16 RXD במודול; D7 RX אל pin 17 TXD. D0 אל pin 5 PTT עם 10kΩ pull-up ל־3.3V. pin 6 PD ל־3.3V. השאירו pin 7 H/L פתוח כרגע.
חברו EC11: A ל־D1, B ל־D2, SW ל־D3 והמשותף ל־GND. חברו כפתור הצד בין D10 ל־GND. התוכנה משתמשת ב־INPUT_PULLUP.
חברו pin 3 AF_OUT לכניסת PAM8302, אדמה משותפת, ואת הרמקול ליציאת המגבר. התחילו בעוצמה מינימלית והרחיקו את חוטי הרמקול מהאנטנה.
MAX4466 OUT → קבל 1µF → קצה עליון של טרימר 100kΩ; קצה תחתון ל־GND; הווייפר ל־pin 18 MIC_IN. כוונו את הווייפר כמעט ל־GND לפני הפעלה.
הלחימו פיגטייל קצר מ־pin 12 ANT למחבר SMA עם מסלול 50Ω סביר ואדמה רחבה. חברו כרגע עומס דמה 50Ω/2W — לא אנטנה.
פתחו את קובץ PlatformIO המצורף, חברו USB-C והעלו. אל תשנו את TX_ARMED. אחרי reset החוגה אמורה להחליף ארבעה ערוצי RX והמסך להציג RSSI.
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
// PocketLink UHF — XIAO ESP32-C3 + SA868S-UHF
// ברירת המחדל היא RX בלבד. שינוי TX_ARMED מותר רק לבעל רישיון,
// על תדר שהוקצה לו, ותחילה לעומס דמה 50Ω.
constexpr bool TX_ARMED = false;
constexpr uint8_t PIN_RADIO_PTT = D0; // אל SA868S pin 5, עם pull-up של 10k
constexpr uint8_t PIN_ENC_A = D1;
constexpr uint8_t PIN_ENC_B = D2;
constexpr uint8_t PIN_ENC_SW = D3;
constexpr uint8_t PIN_USER_PTT = D10;
constexpr uint8_t PIN_I2C_SDA = D4;
constexpr uint8_t PIN_I2C_SCL = D5;
constexpr uint8_t PIN_RADIO_TX = D6; // אל SA868S RXD, pin 16
constexpr uint8_t PIN_RADIO_RX = D7; // אל SA868S TXD, pin 17
constexpr uint8_t OLED_ADDR = 0x3C;
constexpr uint8_t OLED_W = 128;
constexpr uint8_t OLED_H = 64;
Adafruit_SSD1306 display(OLED_W, OLED_H, &Wire, -1);
HardwareSerial radio(1);
struct Channel {
const char *name;
const char *rxMHz;
const char *txMHz;
const char *ctcss;
bool txAllowed;
};
// דוגמאות קליטה בפס 70 ס"מ. לפני שימוש אמיתי מחליפים בהתאם לרישיון
// ולתכנית הפס העדכנית. שום ערוץ אינו מורשה לשידור בקובץ שמגיע מהאתר.
Channel channels[] = {
{"70CM 1", "430.0000", "430.0000", "0000", false},
{"70CM 2", "433.5000", "433.5000", "0000", false},
{"70CM 3", "438.5000", "438.5000", "0000", false},
{"70CM 4", "439.0000", "439.0000", "0000", false},
};
constexpr size_t CHANNEL_COUNT = sizeof(channels) / sizeof(channels[0]);
size_t channelIndex = 0;
int lastEncA = HIGH;
bool transmitting = false;
bool moduleReady = false;
String lastReply;
unsigned long lastRssiAt = 0;
unsigned long lastDrawAt = 0;
int rssiValue = -1;
String readReply(uint32_t timeoutMs = 350) {
String reply;
const uint32_t started = millis();
while (millis() - started < timeoutMs) {
while (radio.available()) {
const char c = static_cast<char>(radio.read());
reply += c;
if (reply.endsWith("\r\n")) return reply;
}
delay(1);
}
return reply;
}
bool command(const String &text, const char *successToken, uint32_t timeoutMs = 350) {
while (radio.available()) radio.read();
radio.print(text);
radio.print("\r\n");
lastReply = readReply(timeoutMs);
return lastReply.indexOf(successToken) >= 0;
}
bool configureChannel() {
const Channel &ch = channels[channelIndex];
// 1 = low power. גם אם TX ייפתח בעתיד, מתחילים בהספק הנמוך.
const String group = "AT+DMOSETGROUP=1," + String(ch.txMHz) + "," +
String(ch.rxMHz) + "," + String(ch.ctcss) + ",4," +
String(ch.ctcss);
return command(group, "+DMOSETGROUP:0", 600);
}
void drawScreen() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0, 0);
display.print("POCKETLINK ");
display.println(moduleReady ? "RADIO OK" : "CHECK RADIO");
display.setTextSize(2);
display.setCursor(0, 16);
display.println(channels[channelIndex].rxMHz);
display.setTextSize(1);
display.setCursor(0, 42);
display.print(channelIndex + 1);
display.print("/");
display.print(CHANNEL_COUNT);
display.print(" ");
display.print(channels[channelIndex].name);
display.setCursor(0, 54);
if (transmitting) {
display.print("TX");
} else if (!TX_ARMED) {
display.print("RX ONLY");
} else {
display.print("RX READY");
}
if (rssiValue >= 0) {
display.print(" RSSI ");
display.print(rssiValue);
}
display.display();
}
void setChannel(int direction) {
if (transmitting) return;
const int next =
(static_cast<int>(channelIndex) + direction + CHANNEL_COUNT) % CHANNEL_COUNT;
if (next == static_cast<int>(channelIndex)) return;
channelIndex = static_cast<size_t>(next);
moduleReady = configureChannel();
rssiValue = -1;
drawScreen();
}
void pollEncoder() {
const int a = digitalRead(PIN_ENC_A);
if (a != lastEncA && a == LOW) {
const int direction = digitalRead(PIN_ENC_B) == HIGH ? 1 : -1;
setChannel(direction);
}
lastEncA = a;
}
void pollPtt() {
const bool pressed = digitalRead(PIN_USER_PTT) == LOW;
const bool mayTransmit = TX_ARMED && channels[channelIndex].txAllowed;
const bool nextTx = pressed && mayTransmit;
if (nextTx == transmitting) return;
transmitting = nextTx;
digitalWrite(PIN_RADIO_PTT, transmitting ? LOW : HIGH);
drawScreen();
}
void requestRssi() {
if (transmitting || millis() - lastRssiAt < 1500) return;
lastRssiAt = millis();
if (!command("AT+RSSI?", "RSSI:", 250)) return;
const int colon = lastReply.indexOf(':');
if (colon >= 0) rssiValue = lastReply.substring(colon + 1).toInt();
}
void setup() {
pinMode(PIN_RADIO_PTT, OUTPUT);
digitalWrite(PIN_RADIO_PTT, HIGH); // RX לפני כל אתחול של התצוגה/רדיו
pinMode(PIN_USER_PTT, INPUT_PULLUP);
pinMode(PIN_ENC_A, INPUT_PULLUP);
pinMode(PIN_ENC_B, INPUT_PULLUP);
pinMode(PIN_ENC_SW, INPUT_PULLUP);
Serial.begin(115200);
Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL);
display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
drawScreen();
radio.begin(9600, SERIAL_8N1, PIN_RADIO_RX, PIN_RADIO_TX);
delay(650);
moduleReady = command("AT+DMOCONNECT", "+DMOCONNECT:0", 600);
if (moduleReady) {
moduleReady = configureChannel();
command("AT+DMOSETVOLUME=5", "+DMOSETVOLUME:0");
}
drawScreen();
}
void loop() {
pollEncoder();
pollPtt();
requestRssi();
if (millis() - lastDrawAt > 500) {
lastDrawAt = millis();
drawScreen();
}
delay(2);
}
[env:seeed_xiao_esp32c3]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip
board = seeed_xiao_esp32c3
framework = arduino
monitor_speed = 115200
lib_deps =
adafruit/Adafruit GFX Library@^1.12.3
adafruit/Adafruit SSD1306@^2.5.15
עברו עם החוגה על כל ארבעת הערוצים. בכל ערוץ המתינו 10 שניות, צפו ב־RSSI והקשיבו לשינוי הסקוולץ'.
כוונו קודם את עוצמת PAM8302 לקליטה ברורה. השאירו את מסלול המיקרופון במינימום. סגרו חלקית את המארז ובדקו שאין צפצוף.
שלב זה מיועד רק לבעל רישיון מתאים. בחרו תדר והספק שמותרים ברישיון, השאירו LOW POWER, פתחו txAllowed לערוץ בדיקה אחד ורק אז TX_ARMED=true. בנו מחדש ובצעו לחיצה קצרה לעומס דמה 50Ω.
מקמו רמקול ומיקרופון בשני קצוות, את ה־SMA רחוק מקווי השמע ואת החוגה בראש המארז. קבעו את הסוללה בלי לחץ והשאירו גישה ל־USB-C, מפסק ופיוז.
במצב RX בדקו יציבות 20 דקות. בעל רישיון בלבד ממשיך לניסוי שטח: 100m, 500m, 1km ובהמשך לפי תנאים. רשמו RSSI, קו ראייה, גובה ואיכות קול.