From c32f00a2bed4433a0a42422ffdcca2f4e11a40fa Mon Sep 17 00:00:00 2001 From: Laurence Date: Sun, 17 May 2026 08:51:29 +0100 Subject: [PATCH] =?UTF-8?q?Initial=20release=20v1.1.0=20=E2=80=94=20ESP32?= =?UTF-8?q?=20automated=20telescope=20flat=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmware (Arduino Nano ESP32 / PlatformIO): - Native ASCOM Alpaca CoverCalibrator REST API on port 11111 - Alnitak serial protocol on USB at 9600 baud (simultaneous with WiFi) - MG995 servo cover mechanism with non-blocking state machine (D9) - LED brightness PWM via IRLZ44N MOSFET, LEDC channel 0 (D3) - 12V dew heater PWM via IRLZ44N MOSFET, LEDC channel 1 (D5) - mDNS + UDP Alpaca discovery, WiFi watchdog reconnect - SERIAL_DEBUG flag to silence debug output in USB-only mode INDI driver (C++ / libcurl / nlohmann-json): - WiFi mode: HTTP Alpaca via libcurl - USB mode: Alnitak serial via POSIX termios - LightBoxInterface + DustCapInterface + dew heater number property Python controller (PyQt6): - Dark-themed desktop app for direct manual control - AlpacaBackend (requests) + AlnitakBackend (pyserial) - PollWorker QThread; cover, brightness, dew heater panels - QSettings persistence; auto serial port discovery Docs: - system-diagram.svg, wiring-diagram.svg (browser-renderable SVG) - BUILD_NOTES.md with BOM, LM2596 calibration, power-on checklist - WIRING.md quick-reference, README.md, CHANGELOG.md --- CHANGELOG.md | 101 +++ README.md | 275 ++++++++ WIRING.md | 177 ++++++ controller/flatpanel_controller.py | 909 +++++++++++++++++++++++++++ controller/requirements.txt | 3 + docs/BUILD_NOTES.md | 289 +++++++++ docs/system-diagram.svg | 282 +++++++++ docs/wiring-diagram.svg | 313 +++++++++ firmware/platformio.ini | 14 + firmware/src/config.h | 58 ++ firmware/src/main.cpp | 515 +++++++++++++++ indi-driver/CMakeLists.txt | 39 ++ indi-driver/indi_esp32_flatpanel.cpp | 581 +++++++++++++++++ indi-driver/indi_esp32_flatpanel.h | 88 +++ indi-driver/indi_esp32_flatpanel.xml | 9 + 15 files changed, 3653 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 WIRING.md create mode 100644 controller/flatpanel_controller.py create mode 100644 controller/requirements.txt create mode 100644 docs/BUILD_NOTES.md create mode 100644 docs/system-diagram.svg create mode 100644 docs/wiring-diagram.svg create mode 100644 firmware/platformio.ini create mode 100644 firmware/src/config.h create mode 100644 firmware/src/main.cpp create mode 100644 indi-driver/CMakeLists.txt create mode 100644 indi-driver/indi_esp32_flatpanel.cpp create mode 100644 indi-driver/indi_esp32_flatpanel.h create mode 100644 indi-driver/indi_esp32_flatpanel.xml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1b172cd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,101 @@ +# Changelog + +All notable changes to this project are documented here. +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +--- + +## [1.1.0] — 2026-05-17 + +### Added + +#### Dew heater +- 12V PWM-controlled dew heater output on **D5** via a second IRLZ44N MOSFET +- `applyDewHeater(int pct)` firmware function maps 0–100% to 8-bit LEDC PWM +- Non-standard Alpaca GET/PUT endpoint `dewheater` for direct polling and control by the Python app and INDI driver +- Dew heater exposed as `SupportedActions` entries `SetDewHeater` / `GetDewHeater` for full Alpaca Action compatibility +- Custom Alnitak extension commands: `>T` (set percent) / `>U` (get percent) +- Dew heater number property in the INDI driver (`MAIN_CONTROL_TAB`) +- Dew heater slider + On/Off buttons in the Python controller + +#### USB / Alnitak serial mode +- Full implementation of the **Alnitak Flip-Flat serial protocol** on the ESP32 USB CDC port (9600 baud) +- Supports all standard Alnitak commands: `P` ping, `S` state, `B`/`J` brightness set/get, `L`/`D` light on/off, `O`/`C`/`H` cover open/close/halt +- Compatible with N.I.N.A., Sequence Generator Pro, Astro Photography Tool, and INDI `indi_flipflat` out of the box — no additional driver required +- `SERIAL_DEBUG` compile flag in `config.h`: set `false` to silence all debug output and use the serial line exclusively for the Alnitak protocol +- Serial baud rate set to **9600** to match all consumer Alnitak software defaults + +#### Python desktop controller (`controller/flatpanel_controller.py`) +- Dark-themed PyQt6 application suitable for night-time observatory use +- Supports both WiFi/Alpaca and USB/Alnitak connection modes selectable at runtime +- `AlpacaBackend` — HTTP via `requests.Session`, full Alpaca property access including custom `dewheater` endpoint +- `AlnitakBackend` — `pyserial` serial communication with timeout handling and response validation +- `PollWorker` — `QThread` subclass polling device state every 1 second without blocking the GUI +- Cover panel: status dot (colour-coded by state), Open / Close / Halt buttons +- Flat panel: brightness slider linked bidirectionally to a spinbox, Light On/Off +- Dew heater panel: power slider (0–100%), Heater On/Off, live % status indicator +- Scrolling, timestamped log panel (500-line cap) +- `QSettings` persistence for connection mode, host, port, serial port, last brightness, last dew % +- Auto-discovery of available serial ports with refresh button +- `requirements.txt` added: `PyQt6`, `requests`, `pyserial` + +#### INDI driver enhancements +- **USB serial mode** added alongside existing WiFi/Alpaca mode; switchable via a `Connection Mode` switch property in the driver UI +- Serial port text property (`/dev/ttyUSB0` default) shown when USB mode is selected +- POSIX serial port implementation using `termios` / `select` with 2-second read timeout +- Alnitak helper methods: `openSerial`, `closeSerial`, `sendAlnitak`, `alnitakGetBrightness`, `alnitakSetBrightness`, `alnitakGetCoverState`, `alnitakGetDewPercent`, `alnitakSetDewPercent` +- `DewHeaterNP` number property (`0–100%`, step 5) exposed in `MAIN_CONTROL_TAB` +- Dew heater control routes through Alpaca `dewheater` PUT (WiFi) or custom Alnitak `>T` (USB) + +#### Documentation +- `docs/system-diagram.svg` — architecture block diagram (800×520, browser-renderable SVG) +- `docs/wiring-diagram.svg` — colour-coded component wiring diagram (960×640, browser-renderable SVG), includes build-order warning panel and legend +- `docs/BUILD_NOTES.md` — full step-by-step assembly guide with BOM, dew heater sizing table, LM2596 calibration procedure, MOSFET wiring, 7-step first power-on checklist, software setup for all platforms, and troubleshooting table + +### Changed +- `Serial.begin()` baud rate changed from `115200` to **9600** to match Alnitak software defaults (USB CDC baud rate is virtual and has no real-world effect, but driver compatibility requires this) +- `supportedactions` endpoint now returns `["SetDewHeater", "GetDewHeater"]` instead of an empty array +- `description` endpoint updated to mention dew heater +- `driverversion` bumped to `1.1.0` +- WIRING.md updated: second MOSFET added to wiring diagram, dew heater element sizing table, USB/WiFi mode comparison table, pin summary table, PSU rating increased from ≥3A to **≥4A** + +### Fixed +- Cover `haltcover` Alpaca route now correctly reads current servo position via `coverServo.read()` before holding, preventing a potential servo jitter on halt + +--- + +## [1.0.0] — 2026-05-17 + +Initial release. + +### Added + +#### Firmware +- ASCOM Alpaca `CoverCalibrator` device type implemented natively on the ESP32 (no PC-side COM driver needed) +- All standard `CoverCalibrator` properties and methods: + - GET: `coverstate`, `calibratorstate`, `brightness`, `maxbrightness`, `connected`, `name`, `description`, `driverinfo`, `driverversion`, `interfaceversion`, `supportedactions` + - PUT: `connected`, `calibratoron` (with `Brightness` parameter), `calibratoroff`, `opencover`, `closecover`, `haltcover` +- MG995 servo cover mechanism on D9; non-blocking state machine with configurable movement timeout (`COVER_MOVE_TIMEOUT_MS`) +- 8-bit LED brightness PWM on D3 via LEDC channel 0 (5kHz, above visible flicker) +- WiFi connection with 30-second boot timeout and 10-second watchdog reconnect loop +- mDNS advertising as `esp32-flatpanel.local` with `_alpaca._tcp` service record +- Alpaca UDP discovery on port 32227 — responds to `alpacadiscovery1` broadcasts with `{"AlpacaPort":11111}` +- `config.h` with all user-configurable settings in one place + +#### INDI driver (WiFi / Alpaca mode) +- `INDI::DefaultDevice` + `INDI::LightBoxInterface` + `INDI::DustCapInterface` +- `AlpacaBackend` using `libcurl` for HTTP, `nlohmann/json` for response parsing +- 1-second state polling via `TimerHit()` +- `LightBoxInterface`: `SetLightBoxBrightness()`, `EnableLightBox()` wired to Alpaca `calibratoron`/`calibratoroff` +- `DustCapInterface`: `ParkCap()` → `closecover`, `UnParkCap()` → `opencover` +- Host / port text properties in `CONNECTION_TAB` +- INDI device XML descriptor for indiserver auto-registration +- `CMakeLists.txt` with `FetchContent` fallback for `nlohmann_json` + +#### Documentation +- `WIRING.md` — wiring diagram, BOM, component roles, MOSFET pinout, servo colour codes, ASCOM and INDI setup instructions + +--- + +[1.1.0]: https://github.com/your-repo/flatpanel/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/your-repo/flatpanel/releases/tag/v1.0.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3430a14 --- /dev/null +++ b/README.md @@ -0,0 +1,275 @@ +# ESP32 FlatPanel + +An open-source automated telescope flat-field panel built around the **Arduino Nano ESP32**. +Controls LED brightness, a motorised cover, and a dew heater — all accessible from ASCOM, INDI, and a standalone desktop application. + +--- + +## Features + +| Feature | Detail | +|---------|--------| +| **LED brightness** | 8-bit PWM (0–255) via logic-level MOSFET | +| **Motorised cover** | MG995 servo rotates panel in/out of the telescope light path | +| **Dew heater** | 12V resistive element, 0–100% PWM — keeps the diffuser clear | +| **WiFi mode** | Native ASCOM Alpaca REST API on port 11111; auto-discovered via mDNS and UDP broadcast | +| **USB mode** | Alnitak serial protocol at 9600 baud — plug-and-play with N.I.N.A., SGPro, APT and `indi_flipflat` | +| **Both simultaneously** | WiFi and USB are active at the same time in a single firmware build | +| **ASCOM** | Use ASCOM Remote (free, official) — no custom COM driver required | +| **INDI** | Custom `indi_esp32_flatpanel` driver with WiFi and USB connection modes | +| **Desktop app** | Dark-themed PyQt6 controller for direct manual operation | + +--- + +## Repository layout + +``` +flatpanel/ +├── firmware/ ESP32 firmware (PlatformIO / Arduino) +│ ├── platformio.ini +│ └── src/ +│ ├── config.h ← edit this first +│ └── main.cpp +├── indi-driver/ INDI driver (C++, Linux) +│ ├── CMakeLists.txt +│ ├── indi_esp32_flatpanel.h +│ ├── indi_esp32_flatpanel.cpp +│ └── indi_esp32_flatpanel.xml +├── controller/ Python desktop controller (all platforms) +│ ├── flatpanel_controller.py +│ └── requirements.txt +├── docs/ +│ ├── system-diagram.svg Block diagram — open in any browser +│ ├── wiring-diagram.svg Colour-coded wiring — open in any browser +│ └── BUILD_NOTES.md Step-by-step assembly guide +├── WIRING.md Quick-reference wiring + BOM +├── README.md This file +└── CHANGELOG.md +``` + +--- + +## Hardware required + +| Component | Role | Pin | +|-----------|------|-----| +| Arduino Nano ESP32 | Main controller | — | +| MG995 servo | Cover open/close | D9 | +| IRLZ44N MOSFET × 2 | LED brightness + dew heater | D3, D5 | +| LM2596 buck module | 12V → 5V for ESP32 + servo | — | +| 12V PSU ≥ 4A | Powers everything | — | +| White LED strip 12V (Ra ≥ 90) | Flat-field illumination | — | +| Frosted acrylic 3mm | Light diffuser | — | +| Dew heater element 12V ~24Ω | Anti-dew resistive heater | — | +| 100Ω resistor × 2 | MOSFET gate series | — | +| 10kΩ resistor × 2 | MOSFET gate pull-down | — | + +See [docs/BUILD_NOTES.md](docs/BUILD_NOTES.md) for the full BOM, dew heater sizing table, and step-by-step assembly instructions. +See [docs/wiring-diagram.svg](docs/wiring-diagram.svg) for the colour-coded wiring diagram. + +--- + +## Quick-start + +### 1 Configure the firmware + +Edit `firmware/src/config.h`: + +```cpp +#define WIFI_SSID "your_network" +#define WIFI_PASSWORD "your_password" + +// Tune to your mechanical stops after assembly: +#define SERVO_CLOSED 0 // panel in flat position +#define SERVO_OPEN 90 // panel clear of telescope + +// Set false when using USB/Alnitak mode to keep the serial line clean: +#define SERIAL_DEBUG true +``` + +### 2 Flash + +```bash +cd flatpanel +pio run -t upload # PlatformIO CLI +# or use the PlatformIO IDE extension in VS Code +``` + +### 3 Verify + +Open a serial monitor at **9600 baud**. +You should see the ESP32's IP address printed on boot. +Browse to: + +``` +http://esp32-flatpanel.local:11111/management/v1/configureddevices +``` + +Expected response contains `"DeviceType":"CoverCalibrator"`. + +--- + +## Connection modes + +### WiFi — ASCOM Alpaca (no driver needed) + +The ESP32 **is** an Alpaca server. Use **ASCOM Remote** as the bridge to ASCOM COM: + +1. Install [ASCOM Platform 6.6+](https://ascom-standards.org/Downloads/Index.htm) and [ASCOM Remote](https://github.com/ASCOMInitiative/ASCOM.Remote/releases). +2. In ASCOM Remote, add a **CoverCalibrator** device → host `esp32-flatpanel.local`, port `11111`, device `0`. +3. In N.I.N.A. / SGPro / APT, choose **ASCOM CoverCalibrator** and select the ASCOM Remote device. + +The ESP32 is also discovered automatically by software that supports Alpaca UDP discovery (N.I.N.A. 3+, Cartes du Ciel). + +### WiFi — INDI + +```bash +# Build and install the custom driver (once) +cd flatpanel/indi-driver +cmake -B build -DCMAKE_INSTALL_PREFIX=/usr +cmake --build build -j$(nproc) +sudo cmake --install build + +# Start (KStars/Ekos loads it automatically after install) +indiserver indi_esp32_flatpanel +``` + +In Ekos: select **ESP32 FlatPanel**, set Connection Mode to **WiFi / Alpaca**, enter the host and port. + +### USB — Alnitak (plug-and-play) + +Set `SERIAL_DEBUG false` in `config.h` and reflash. Then connect via USB-C. + +The firmware speaks the standard Alnitak protocol so existing software works without any driver: + +| Software | Built-in support | +|----------|-----------------| +| N.I.N.A. | ✅ Alnitak / Flip-Flat | +| Sequence Generator Pro | ✅ Alnitak | +| Astro Photography Tool | ✅ Alnitak | +| INDI (`indi_flipflat`) | ✅ ships with INDI | +| Our INDI driver | ✅ USB mode in Connection tab | + +> **Dew heater via USB:** The standard Alnitak protocol has no dew heater concept. +> Our INDI driver and the Python controller use custom extension commands `>T` (set) / `>U` (get) that are silently ignored by other software. + +### Desktop controller app + +Works in both WiFi and USB mode. Dark-themed for night use. + +```bash +pip install PyQt6 requests pyserial +python flatpanel/controller/flatpanel_controller.py +``` + +--- + +## Alpaca API reference + +Base URL: `http://:11111/api/v1/covercalibrator/0/` + +| Method | Endpoint | Parameters | Description | +|--------|----------|-----------|-------------| +| GET | `coverstate` | — | 2=Open, 3=Closed, 4=Moving | +| GET | `calibratorstate` | — | 1=Off, 3=Ready | +| GET | `brightness` | — | Current brightness 0–255 | +| GET | `maxbrightness` | — | Always 255 | +| GET | `dewheater` | — | Current dew heater 0–100% *(custom)* | +| PUT | `opencover` | — | Rotate panel out of light path | +| PUT | `closecover` | — | Rotate panel into flat position | +| PUT | `haltcover` | — | Stop cover movement | +| PUT | `calibratoron` | `Brightness=N` | Turn on LED at brightness N | +| PUT | `calibratoroff` | — | Turn off LED | +| PUT | `dewheater` | `Percentage=N` | Set dew heater 0–100% *(custom)* | +| PUT | `action` | `Action=SetDewHeater`
`Parameters=N` | Alpaca-standard alternative to PUT dewheater | +| PUT | `action` | `Action=GetDewHeater` | Returns current dew % as string Value | + +Management endpoints: `/management/apiversions`, `/management/v1/description`, `/management/v1/configureddevices` + +--- + +## Alnitak serial protocol reference + +Port: USB-C at 9600 baud, 8N1. +Command format: `>CXXX\r` · Response format: `*CDDVVV\r` (DD = device type 19). + +| Cmd | Description | Example | +|-----|-------------|---------| +| `>P000` | Ping | `*P19000` | +| `>S000` | State (motor + light) | `*S19` + motor(1) + light(0) + `00` | +| `>B128` | Set brightness 0–255 | `*B19128` | +| `>J000` | Get brightness | `*J19128` | +| `>L000` | Light on (keep current brightness) | `*L19000` | +| `>D000` | Light off | `*D19000` | +| `>O000` | Open cover | `*O19000` | +| `>C000` | Close cover | `*C19000` | +| `>H000` | Halt cover | `*H19000` | +| `>T050` | **Set dew heater 50%** *(custom)* | `*T19050` | +| `>U000` | **Get dew heater %** *(custom)* | `*U19050` | + +Motor state in `>S000` response: `0`=unknown/moving · `1`=closed · `2`=open +Light state: `0`=off · `1`=on + +--- + +## Pin assignments + +| D-pin | GPIO | Function | Config define | +|-------|------|----------|---------------| +| D9 | — | MG995 servo signal | `SERVO_PIN` | +| D3 | — | LED MOSFET gate | `LED_PWM_PIN` | +| D5 | — | Dew heater MOSFET gate | `DEW_HEATER_PIN` | + +All pins use the Arduino Nano ESP32 D-number scheme. +LEDC channel 0 → LED · channel 1 → dew heater. + +--- + +## Building the INDI driver + +**Dependencies:** `libindi-dev`, `libcurl4-openssl-dev`, `nlohmann-json3-dev`, `cmake` + +```bash +sudo apt install libindi-dev libcurl4-openssl-dev nlohmann-json3-dev cmake build-essential + +cd flatpanel/indi-driver +cmake -B build -DCMAKE_INSTALL_PREFIX=/usr +cmake --build build -j$(nproc) +sudo cmake --install build +``` + +If `nlohmann-json3-dev` is unavailable, CMake will fetch it automatically via `FetchContent`. + +--- + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| No boot message on Serial | Check VIN wiring; try USB-C direct to PC (bypasses LM2596) | +| Servo twitches constantly | LM2596 sagging; re-measure 5V under servo load | +| LED stays on at 0% | Missing 10kΩ gate pull-down on MOSFET-A | +| Alnitak commands ignored | Set `SERIAL_DEBUG false`, reflash | +| `Connection refused` on port 11111 | ESP32 not yet connected to WiFi; check credentials | +| Dew heater not warming | Verify MOSFET-B drain voltage ≈ 0V when active; check element resistance | +| Python app import error | `pip install PyQt6 requests pyserial` | +| INDI build fails | `sudo apt install libindi-dev` | + +--- + +## Diagrams + +| File | Description | +|------|-------------| +| [docs/system-diagram.svg](docs/system-diagram.svg) | Architecture block diagram | +| [docs/wiring-diagram.svg](docs/wiring-diagram.svg) | Colour-coded wiring diagram | + +Open either file in a web browser — no additional software needed. + +--- + +## Licence + +Released under the **MIT Licence** — see `LICENCE` file. +ASCOM and Alpaca are trademarks of the ASCOM Initiative. +INDI is maintained by the INDI developers at indilib.org. diff --git a/WIRING.md b/WIRING.md new file mode 100644 index 0000000..c2a3dd1 --- /dev/null +++ b/WIRING.md @@ -0,0 +1,177 @@ +# ESP32 FlatPanel – Wiring & BOM + +## What to buy + +| Item | Purpose | Notes | +|------|---------|-------| +| IRLZ44N N-channel MOSFET × 2 | LED brightness + dew heater control | Logic-level: 3.3 V gate from ESP32 is sufficient. One per load. | +| 100 Ω resistor × 2 (1/4 W) | MOSFET gate current limiting | One per MOSFET | +| 10 kΩ resistor × 2 (1/4 W) | MOSFET gate pull-down | Ensures loads are off at boot | +| White LED strip or panel (12 V) | Flat-field illumination surface | High-CRI (Ra > 90) recommended. Size to scope aperture. | +| Frosted acrylic sheet | Light diffuser | 3 mm thick, cut to scope outer diameter | +| Dew heater element (12 V, ~5-10 W) | Prevents flat panel misting up | Nichrome wire mat, resistive heating tape, or commercial dew strap cut to fit. ~24 Ω for 6 W at 12 V. | +| LM2596 or MP1584 buck converter module | 12 V → 5 V for servo | Set output to 5.0 V before connecting | +| 12 V DC power supply (≥ 4 A) | Powers everything | LED 2 A + dew heater 0.5 A + servo 1 A peak | +| 1N4007 diode | Optional: reverse-polarity protection on 12 V input | | +| Prototype / strip board | Assembly | | +| JST connectors or screw terminals | Clean connections | | +| Aluminium or 3D-printed enclosure | Mechanical housing | | + +--- + +## Component roles + +| Component | Role | +|-----------|------| +| Arduino Nano ESP32 | Main controller: WiFi, Alpaca server, PWM, servo | +| MG995 servo | Rotates the flat panel lid in / out of the telescope light path | +| SG90 micro servo | Spare / future use (e.g., secondary aperture cover) | +| 28BYJ-48 stepper | Spare / future use (e.g., precision ND-filter wheel for extra dimming) | +| IRLZ44N MOSFET | Controls LED strip power via PWM from ESP32 | + +--- + +## Wiring diagram + +``` +12 V PSU ──────────────────────────────────────────── 12 V rail + │ │ + │ LM2596 buck (set to 5 V) │ + ├──[IN+]──[OUT+]──────────────── 5 V rail │ + │ [IN-]──[OUT-]─────────────── GND rail │ + │ │ + └── GND ──────────────────────── GND rail │ + │ + ┌─────────── Arduino Nano ESP32 ───────────────────┐ │ + │ VIN ◄──── 5 V rail │ │ + │ GND ◄──── GND rail │ │ + │ │ │ + │ D9 ──────────────────────────► MG995 Signal │ │ + │ 5 V rail ─────────────────────► MG995 VCC (5V) │ │ + │ GND ──────────────────────────► MG995 GND │ │ + │ │ │ + │ D3 ──[100 Ω]──► MOSFET-A Gate (LED) │ │ + │ GND ──[10 kΩ]──► MOSFET-A Gate (pull-down) │ │ + │ MOSFET-A Source ──► GND rail │ │ + │ MOSFET-A Drain ──► LED- │ │ + │ │ │ + │ D5 ──[100 Ω]──► MOSFET-B Gate (dew heater) │ │ + │ GND ──[10 kΩ]──► MOSFET-B Gate (pull-down) │ │ + │ MOSFET-B Source ──► GND rail │ │ + │ MOSFET-B Drain ──► Heater- │ │ + └──────────────────────────────────────────────────┘ │ + │ + LED strip/panel: LED+ ◄────────────────── 12 V rail ──┤ + LED- ◄────────────────── MOSFET-A Drain + │ + Dew heater: Heater+ ◄────────────────── 12 V rail ───┘ + Heater- ◄────────────────── MOSFET-B Drain +``` + +### MOSFET pinout (IRLZ44N TO-220, flat face towards you) +``` + Gate | Drain | Source + 1 2 3 +``` +Connect: +- Gate (1) → 100 Ω → D3, with 10 kΩ from Gate to GND +- Drain (2) → LED strip negative lead +- Source (3) → GND rail + +--- + +## Servo signal cable colours (standard) + +| Colour | Signal | +|--------|--------| +| Brown / Black | GND | +| Red | VCC (5 V) | +| Orange / Yellow / White | Signal (PWM) | + +--- + +## Pin summary + +| ESP32 pin | Function | Config define | +|-----------|----------|---------------| +| D9 | MG995 servo signal | `SERVO_PIN` | +| D3 | LED PWM (MOSFET-A gate) | `LED_PWM_PIN` | +| D5 | Dew heater PWM (MOSFET-B gate) | `DEW_HEATER_PIN` | + +--- + +## Dew heater element sizing + +| 12 V supply | Resistance | Power | Suitable for panel size | +|-------------|------------|-------|------------------------| +| 48 Ω | 3 W | Up to 3″ aperture | +| 24 Ω | 6 W | 4-6″ aperture (recommended) | +| 12 Ω | 12 W | 8″+ aperture | + +Use nichrome wire, resistive heating tape, or a commercial dew strap wound flat under the acrylic diffuser. The MOSFET PWM keeps average power proportional to the 0-100 % setting — you do not need to match exact resistance; just aim for 5-10 W at 12 V. + +--- + +## Choosing between WiFi and USB mode + +| | WiFi (Alpaca) | USB Serial (Alnitak) | +|-|--------------|---------------------| +| Cable required | No | Yes (USB-A to USB-C) | +| Works with | ASCOM Remote, our INDI driver | N.I.N.A. built-in, SGPro, APT, indi_flipflat, our INDI driver | +| Dew heater control | Via Alpaca Action (our driver) | Via custom T/U serial commands (our driver only) | +| Debug output | Set `SERIAL_DEBUG true` | Set `SERIAL_DEBUG false` | +| Discovery | mDNS + UDP broadcast | COM port / /dev/ttyUSBx | + +For USB mode, edit `config.h` and set `SERIAL_DEBUG false` before flashing, then open the COM port at **9600 baud** in your software. + +--- + +## First-time setup checklist + +1. Set LM2596 output to **5.0 V** (measure with multimeter) *before* connecting servo. +2. Edit `firmware/src/config.h` — set `WIFI_SSID`, `WIFI_PASSWORD`. + - For USB-only use: also set `SERIAL_DEBUG false`. +3. Adjust `SERVO_OPEN` / `SERVO_CLOSED` angles for your mechanical setup. +4. Flash firmware via PlatformIO: `pio run -t upload`. +5. **WiFi mode**: open Serial Monitor (9600 baud) — note the printed IP address, then browse to `http://:11111/management/v1/configureddevices`. +6. **USB mode**: connect to the COM port at 9600 baud and send `>P000` — you should receive `*P19000`. + +--- + +## ASCOM setup (Windows) + +1. Install [ASCOM Platform 6.6+](https://ascom-standards.org/Downloads/Index.htm). +2. Install [ASCOM Remote](https://github.com/ASCOMInitiative/ASCOM.Alpaca.Simulators/releases) (acts as Alpaca → COM bridge). +3. In ASCOM Remote, add a **CoverCalibrator** device pointing to `http://esp32-flatpanel.local:11111`. +4. In your imaging software (N.I.N.A., SGPro, APT) select the "ASCOM Remote CoverCalibrator" driver. + +--- + +## INDI setup (Linux / Raspberry Pi) + +```bash +# Build dependencies +sudo apt install libindi-dev libcurl4-openssl-dev nlohmann-json3-dev cmake + +# Build +cd flatpanel/indi-driver +cmake -B build -DCMAKE_INSTALL_PREFIX=/usr +cmake --build build -j$(nproc) +sudo cmake --install build + +# Run (KStars/Ekos will load it automatically once installed) +# Or manually: +indiserver indi_esp32_flatpanel +``` + +In KStars → Ekos, select **ESP32 FlatPanel** in the Aux device drop-down. + +- **WiFi mode**: select "WiFi / Alpaca" in the Connection Mode switch, enter the host (`esp32-flatpanel.local`) and port (11111), then connect. +- **USB mode**: select "USB Serial (Alnitak)", enter the serial port (`/dev/ttyUSB0` or `/dev/ttyACM0`), then connect. The dew heater slider is available in both modes. + +**Alternative for USB mode (no custom driver needed):** +The existing `indi_flipflat` driver (ships with INDI) speaks standard Alnitak and will control brightness and cover. It will not control the dew heater — use our driver for that. +```bash +indiserver indi_flipflat +# Select port /dev/ttyUSB0, baud 9600 +``` diff --git a/controller/flatpanel_controller.py b/controller/flatpanel_controller.py new file mode 100644 index 0000000..cd562d4 --- /dev/null +++ b/controller/flatpanel_controller.py @@ -0,0 +1,909 @@ +#!/usr/bin/env python3 +""" +ESP32 FlatPanel Controller +=========================== +Manual control application for the ESP32 telescope flat panel. + +Supports: + WiFi mode – ASCOM Alpaca REST API over HTTP + USB mode – Alnitak serial protocol at 9600 baud + +Requirements: + pip install PyQt6 requests pyserial +""" + +from __future__ import annotations + +import sys +import threading +from abc import ABC, abstractmethod +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from typing import Optional + +try: + import requests +except ImportError: + print("ERROR: 'requests' not installed. Run: pip install requests") + sys.exit(1) + +try: + import serial + import serial.tools.list_ports +except ImportError: + print("ERROR: 'pyserial' not installed. Run: pip install pyserial") + sys.exit(1) + +try: + from PyQt6.QtCore import (Qt, QTimer, QSettings, QThread, QSize, + pyqtSignal, QObject) + from PyQt6.QtGui import QColor, QPalette, QFont, QIcon, QPixmap + from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QGroupBox, QLabel, QPushButton, QSlider, QComboBox, QLineEdit, + QPlainTextEdit, QRadioButton, QButtonGroup, QFrame, QSplitter, + QSizePolicy, QSpacerItem, QSpinBox, QToolButton, QStatusBar, + ) +except ImportError: + print("ERROR: 'PyQt6' not installed. Run: pip install PyQt6") + sys.exit(1) + +# ───────────────────────────────────────────────────────────────────────────── +# Cover / calibrator state codes (ASCOM Alpaca) +# ───────────────────────────────────────────────────────────────────────────── + +COVER_LABELS = { + 0: ("NOT PRESENT", "#9e9e9e"), + 1: ("UNKNOWN", "#9e9e9e"), + 2: ("OPEN", "#42a5f5"), # blue – panel clear of telescope + 3: ("CLOSED", "#ff9800"), # orange – panel in flat position + 4: ("MOVING", "#ffeb3b"), # yellow + 5: ("ERROR", "#ef5350"), +} + +CAL_LABELS = { + 0: ("N/A", "#9e9e9e"), + 1: ("OFF", "#9e9e9e"), + 2: ("NOT READY", "#ffeb3b"), + 3: ("ON", "#4caf50"), + 4: ("UNKNOWN", "#9e9e9e"), + 5: ("ERROR", "#ef5350"), +} + +# ───────────────────────────────────────────────────────────────────────────── +# Device state dataclass +# ───────────────────────────────────────────────────────────────────────────── + +class DeviceState: + __slots__ = ("cover", "calibrator", "brightness", "max_brightness", "dew") + + def __init__(self): + self.cover: int = 1 # CoverState.Unknown + self.calibrator: int = 1 # CalibratorState.Off + self.brightness: int = 0 + self.max_brightness: int = 255 + self.dew: int = 0 + +# ───────────────────────────────────────────────────────────────────────────── +# Abstract backend protocol +# ───────────────────────────────────────────────────────────────────────────── + +class Backend(ABC): + @abstractmethod + def connect(self) -> None: ... + @abstractmethod + def disconnect(self) -> None: ... + @abstractmethod + def get_state(self) -> DeviceState: ... + @abstractmethod + def calibrator_on(self, brightness: int) -> None: ... + @abstractmethod + def calibrator_off(self) -> None: ... + @abstractmethod + def open_cover(self) -> None: ... + @abstractmethod + def close_cover(self) -> None: ... + @abstractmethod + def halt_cover(self) -> None: ... + @abstractmethod + def set_dew(self, pct: int) -> None: ... + +# ───────────────────────────────────────────────────────────────────────────── +# WiFi / ASCOM Alpaca backend +# ───────────────────────────────────────────────────────────────────────────── + +class AlpacaBackend(Backend): + _TIMEOUT = 4 + + def __init__(self, host: str, port: int): + self._base = f"http://{host}:{port}/api/v1/covercalibrator/0" + self._tx = 0 + self._lock = threading.Lock() + self._session = requests.Session() + + def _tx_id(self) -> int: + with self._lock: + self._tx += 1 + return self._tx + + def _get(self, prop: str): + url = f"{self._base}/{prop}?ClientID=1&ClientTransactionID={self._tx_id()}" + r = self._session.get(url, timeout=self._TIMEOUT) + r.raise_for_status() + j = r.json() + if j.get("ErrorNumber", -1) != 0: + raise IOError(j.get("ErrorMessage", "Alpaca error")) + return j.get("Value") + + def _put(self, endpoint: str, **params): + body = {"ClientID": 1, "ClientTransactionID": self._tx_id(), **params} + url = f"{self._base}/{endpoint}" + r = self._session.put(url, data=body, timeout=self._TIMEOUT) + r.raise_for_status() + j = r.json() + if j.get("ErrorNumber", -1) != 0: + raise IOError(j.get("ErrorMessage", "Alpaca error")) + + def connect(self) -> None: + self._get("interfaceversion") + self._put("connected", Connected="true") + + def disconnect(self) -> None: + try: + self._put("connected", Connected="false") + except Exception: + pass + self._session.close() + + def get_state(self) -> DeviceState: + s = DeviceState() + s.cover = int(self._get("coverstate")) + s.calibrator = int(self._get("calibratorstate")) + s.brightness = int(self._get("brightness")) + s.max_brightness = int(self._get("maxbrightness")) + try: + s.dew = int(self._get("dewheater")) + except Exception: + s.dew = 0 + return s + + def calibrator_on(self, brightness: int) -> None: + self._put("calibratoron", Brightness=brightness) + + def calibrator_off(self) -> None: + self._put("calibratoroff") + + def open_cover(self) -> None: + self._put("opencover") + + def close_cover(self) -> None: + self._put("closecover") + + def halt_cover(self) -> None: + self._put("haltcover") + + def set_dew(self, pct: int) -> None: + self._put("dewheater", Percentage=pct) + +# ───────────────────────────────────────────────────────────────────────────── +# USB / Alnitak serial backend +# ───────────────────────────────────────────────────────────────────────────── + +class AlnitakBackend(Backend): + _BAUD = 9600 + _TIMEOUT = 2.5 # seconds per command + + def __init__(self, port: str): + self._port_name = port + self._ser: Optional[serial.Serial] = None + self._lock = threading.Lock() + + def _send(self, cmd: str) -> str: + """Send an Alnitak command; return the stripped response line.""" + with self._lock: + if self._ser is None or not self._ser.is_open: + raise IOError("Serial port not open") + self._ser.reset_input_buffer() + self._ser.write((cmd + "\r").encode("ascii")) + self._ser.flush() + # Read until \r or timeout + raw = b"" + while True: + chunk = self._ser.read(64) + if not chunk: + break + raw += chunk + if b"\r" in raw: + break + text = raw.decode("ascii", errors="ignore").strip() + # Return first line that starts with '*' + for line in text.splitlines(): + if line.startswith("*"): + return line + return text + + def _int_payload(self, resp: str) -> int: + """Extract the numeric payload from *CDDVVV (last 3+ chars).""" + if len(resp) >= 5: + try: + return int(resp[4:]) + except ValueError: + pass + return 0 + + def connect(self) -> None: + import time + self._ser = serial.Serial(self._port_name, self._BAUD, timeout=self._TIMEOUT) + time.sleep(1.5) # wait for Arduino CDC reset + resp = self._send(">P000") + if not resp.startswith("*P"): + raise IOError(f"No valid Alnitak ping response: {resp!r}") + + def disconnect(self) -> None: + if self._ser and self._ser.is_open: + self._ser.close() + self._ser = None + + def get_state(self) -> DeviceState: + s = DeviceState() + s.max_brightness = 255 + + state_r = self._send(">S000") + bright_r = self._send(">J000") + dew_r = self._send(">U000") + + # State: *SDD00 + if len(state_r) >= 7: + try: + motor = int(state_r[4]) + light = int(state_r[5]) + s.cover = {1: 3, 2: 2}.get(motor, 4) # Alnitak 1=Closed→3, 2=Open→2 + s.calibrator = 3 if light else 1 + except (ValueError, IndexError): + pass + + if bright_r.startswith("*J") and len(bright_r) >= 7: + try: + s.brightness = int(bright_r[4:7]) + except ValueError: + pass + + if dew_r.startswith("*U") and len(dew_r) >= 7: + try: + s.dew = int(dew_r[4:7]) + except ValueError: + pass + + return s + + def calibrator_on(self, brightness: int) -> None: + self._send(f">B{brightness:03d}") + self._send(">L000") + + def calibrator_off(self) -> None: + self._send(">D000") + + def open_cover(self) -> None: + self._send(">O000") + + def close_cover(self) -> None: + self._send(">C000") + + def halt_cover(self) -> None: + self._send(">H000") + + def set_dew(self, pct: int) -> None: + self._send(f">T{pct:03d}") + +# ───────────────────────────────────────────────────────────────────────────── +# Background polling thread +# ───────────────────────────────────────────────────────────────────────────── + +class PollWorker(QThread): + state_ready = pyqtSignal(object) # DeviceState + poll_error = pyqtSignal(str) + + def __init__(self, backend: Backend, parent=None): + super().__init__(parent) + self._backend = backend + self._stop_evt = threading.Event() + + def run(self): + while not self._stop_evt.is_set(): + try: + state = self._backend.get_state() + self.state_ready.emit(state) + except Exception as exc: + self.poll_error.emit(str(exc)) + self._stop_evt.wait(timeout=1.0) + + def stop(self): + self._stop_evt.set() + self.wait(3000) + +# ───────────────────────────────────────────────────────────────────────────── +# Reusable widgets +# ───────────────────────────────────────────────────────────────────────────── + +class StatusDot(QLabel): + """A coloured ● label used as a status indicator.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setFont(QFont("Segoe UI", 11)) + self.set_state(1) # unknown / grey + + def set_state(self, cover_or_cal: int, table: dict = COVER_LABELS): + text, colour = table.get(cover_or_cal, ("?", "#9e9e9e")) + self.setText( + f'' + f' {text}' + ) + + +class LinkedSliderSpin(QWidget): + """Horizontal slider and spinbox kept in sync.""" + value_committed = pyqtSignal(int) # emitted when user stops dragging + + def __init__(self, lo: int, hi: int, step: int = 1, parent=None): + super().__init__(parent) + self._updating = False + + self.slider = QSlider(Qt.Orientation.Horizontal) + self.slider.setRange(lo, hi) + self.slider.setSingleStep(step) + self.slider.setPageStep(step * 10) + + self.spin = QSpinBox() + self.spin.setRange(lo, hi) + self.spin.setSingleStep(step) + self.spin.setFixedWidth(62) + + lay = QHBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(self.slider, 1) + lay.addWidget(self.spin) + + self.slider.sliderReleased.connect(self._slider_released) + self.slider.valueChanged.connect(self._slider_moved) + self.spin.editingFinished.connect(self._spin_finished) + + def _slider_moved(self, v: int): + if not self._updating: + self._updating = True + self.spin.setValue(v) + self._updating = False + + def _slider_released(self): + self.value_committed.emit(self.slider.value()) + + def _spin_finished(self): + if not self._updating: + self._updating = True + self.slider.setValue(self.spin.value()) + self._updating = False + self.value_committed.emit(self.spin.value()) + + def set_value_silent(self, v: int): + self._updating = True + self.slider.setValue(v) + self.spin.setValue(v) + self._updating = False + + def value(self) -> int: + return self.slider.value() + +# ───────────────────────────────────────────────────────────────────────────── +# Main window +# ───────────────────────────────────────────────────────────────────────────── + +class MainWindow(QMainWindow): + _log_signal = pyqtSignal(str) + _state_signal = pyqtSignal(object) + _err_signal = pyqtSignal(str) + + def __init__(self): + super().__init__() + self.setWindowTitle("ESP32 FlatPanel Controller") + self.setMinimumSize(740, 620) + + self._backend: Optional[Backend] = None + self._poller: Optional[PollWorker] = None + self._pool = ThreadPoolExecutor(max_workers=1) + self._settings = QSettings("FlatPanel", "Controller") + self._last_brightness = 100 + self._last_dew = 0 + self._dew_on = False + + self._log_signal.connect(self._append_log) + self._state_signal.connect(self._apply_state) + self._err_signal.connect(self._on_poll_error) + + self._build_ui() + self._load_settings() + + # ── UI construction ────────────────────────────────────────────────────── + + def _build_ui(self): + root = QWidget() + self.setCentralWidget(root) + main_lay = QVBoxLayout(root) + main_lay.setSpacing(8) + main_lay.setContentsMargins(10, 10, 10, 10) + + main_lay.addWidget(self._build_connection_panel()) + + ctrl_row = QHBoxLayout() + ctrl_row.addWidget(self._build_cover_panel(), 1) + ctrl_row.addWidget(self._build_flat_panel(), 1) + ctrl_row.addWidget(self._build_dew_panel(), 1) + main_lay.addLayout(ctrl_row) + + main_lay.addWidget(self._build_log_panel(), 1) + + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self.status_bar.showMessage("Not connected") + + def _build_connection_panel(self) -> QGroupBox: + gb = QGroupBox("Connection") + lay = QHBoxLayout(gb) + + # Mode radio buttons + mode_box = QVBoxLayout() + self.rb_wifi = QRadioButton("WiFi (Alpaca)") + self.rb_usb = QRadioButton("USB (Alnitak)") + self.rb_wifi.setChecked(True) + self._mode_group = QButtonGroup() + self._mode_group.addButton(self.rb_wifi, 0) + self._mode_group.addButton(self.rb_usb, 1) + mode_box.addWidget(self.rb_wifi) + mode_box.addWidget(self.rb_usb) + lay.addLayout(mode_box) + lay.addSpacing(10) + + # WiFi settings + self._wifi_widget = QWidget() + wifi_lay = QHBoxLayout(self._wifi_widget) + wifi_lay.setContentsMargins(0, 0, 0, 0) + wifi_lay.addWidget(QLabel("Host:")) + self.le_host = QLineEdit("esp32-flatpanel.local") + self.le_host.setMinimumWidth(200) + wifi_lay.addWidget(self.le_host) + wifi_lay.addWidget(QLabel("Port:")) + self.sb_port = QSpinBox() + self.sb_port.setRange(1, 65535) + self.sb_port.setValue(11111) + self.sb_port.setFixedWidth(70) + wifi_lay.addWidget(self.sb_port) + lay.addWidget(self._wifi_widget) + + # USB settings + self._usb_widget = QWidget() + usb_lay = QHBoxLayout(self._usb_widget) + usb_lay.setContentsMargins(0, 0, 0, 0) + usb_lay.addWidget(QLabel("Port:")) + self.cb_serial = QComboBox() + self.cb_serial.setMinimumWidth(180) + self._refresh_ports() + usb_lay.addWidget(self.cb_serial) + btn_refresh = QToolButton() + btn_refresh.setText("⟳") + btn_refresh.setToolTip("Refresh port list") + btn_refresh.clicked.connect(self._refresh_ports) + usb_lay.addWidget(btn_refresh) + lay.addWidget(self._usb_widget) + self._usb_widget.hide() + + lay.addStretch() + + # Connect button + status + self.btn_connect = QPushButton("Connect") + self.btn_connect.setFixedWidth(100) + self.btn_connect.clicked.connect(self._on_connect_toggle) + lay.addWidget(self.btn_connect) + + self.lbl_conn_status = StatusDot() + self.lbl_conn_status.set_state(1) + lay.addWidget(self.lbl_conn_status) + + self.rb_wifi.toggled.connect(self._mode_changed) + self.rb_usb.toggled.connect(self._mode_changed) + return gb + + def _build_cover_panel(self) -> QGroupBox: + gb = QGroupBox("Cover") + lay = QVBoxLayout(gb) + + self.lbl_cover = StatusDot() + self.lbl_cover.set_state(1, COVER_LABELS) + lay.addWidget(self.lbl_cover, alignment=Qt.AlignmentFlag.AlignHCenter) + + lay.addSpacing(8) + + btn_row = QHBoxLayout() + self.btn_open = self._cmd_btn("Open", "#1565c0", self._do_open_cover) + self.btn_close = self._cmd_btn("Close", "#e65100", self._do_close_cover) + btn_row.addWidget(self.btn_open) + btn_row.addWidget(self.btn_close) + lay.addLayout(btn_row) + + self.btn_halt = self._cmd_btn("Halt", "#b71c1c", self._do_halt_cover) + lay.addWidget(self.btn_halt) + lay.addStretch() + self._set_controls_enabled(False) + return gb + + def _build_flat_panel(self) -> QGroupBox: + gb = QGroupBox("Flat Panel") + lay = QVBoxLayout(gb) + + self.lbl_cal = StatusDot() + self.lbl_cal.set_state(1, CAL_LABELS) + lay.addWidget(self.lbl_cal, alignment=Qt.AlignmentFlag.AlignHCenter) + + lay.addSpacing(4) + lay.addWidget(QLabel("Brightness (0 – 255):")) + self.slider_bright = LinkedSliderSpin(0, 255, 1) + self.slider_bright.set_value_silent(100) + self.slider_bright.value_committed.connect(self._brightness_committed) + lay.addWidget(self.slider_bright) + + lay.addSpacing(4) + btn_row = QHBoxLayout() + self.btn_cal_on = self._cmd_btn("Light On", "#2e7d32", self._do_cal_on) + self.btn_cal_off = self._cmd_btn("Light Off", "#555555", self._do_cal_off) + btn_row.addWidget(self.btn_cal_on) + btn_row.addWidget(self.btn_cal_off) + lay.addLayout(btn_row) + lay.addStretch() + return gb + + def _build_dew_panel(self) -> QGroupBox: + gb = QGroupBox("Dew Heater (12 V)") + lay = QVBoxLayout(gb) + + self.lbl_dew = QLabel( + '' + ' OFF 0 %' + ) + self.lbl_dew.setFont(QFont("Segoe UI", 11)) + lay.addWidget(self.lbl_dew, alignment=Qt.AlignmentFlag.AlignHCenter) + + lay.addSpacing(4) + lay.addWidget(QLabel("Power (0 – 100 %):")) + self.slider_dew = LinkedSliderSpin(0, 100, 5) + self.slider_dew.set_value_silent(0) + self.slider_dew.value_committed.connect(self._dew_committed) + lay.addWidget(self.slider_dew) + + lay.addSpacing(4) + btn_row = QHBoxLayout() + self.btn_dew_on = self._cmd_btn("Heater On", "#bf360c", self._do_dew_on) + self.btn_dew_off = self._cmd_btn("Heater Off", "#555555", self._do_dew_off) + btn_row.addWidget(self.btn_dew_on) + btn_row.addWidget(self.btn_dew_off) + lay.addLayout(btn_row) + lay.addStretch() + return gb + + def _build_log_panel(self) -> QGroupBox: + gb = QGroupBox("Log") + lay = QVBoxLayout(gb) + self.log_view = QPlainTextEdit() + self.log_view.setReadOnly(True) + self.log_view.setMaximumBlockCount(500) + self.log_view.setFont(QFont("Courier New", 9)) + lay.addWidget(self.log_view) + + btn_clear = QPushButton("Clear") + btn_clear.setFixedWidth(70) + btn_clear.clicked.connect(self.log_view.clear) + row = QHBoxLayout() + row.addStretch() + row.addWidget(btn_clear) + lay.addLayout(row) + return gb + + @staticmethod + def _cmd_btn(label: str, bg: str, slot) -> QPushButton: + btn = QPushButton(label) + btn.setStyleSheet( + f"QPushButton {{ background-color:{bg}; color:white; " + f"border-radius:4px; padding:5px 10px; }}" + f"QPushButton:hover {{ background-color:{bg}dd; }}" + f"QPushButton:disabled {{ background-color:#333; color:#666; }}" + ) + btn.clicked.connect(slot) + return btn + + # ── Settings ───────────────────────────────────────────────────────────── + + def _load_settings(self): + mode = self._settings.value("mode", "wifi") + self.rb_usb.setChecked(mode == "usb") + self.rb_wifi.setChecked(mode == "wifi") + self.le_host.setText(self._settings.value("host", "esp32-flatpanel.local")) + self.sb_port.setValue(int(self._settings.value("port", 11111))) + saved_serial = self._settings.value("serial_port", "") + idx = self.cb_serial.findText(saved_serial) + if idx >= 0: + self.cb_serial.setCurrentIndex(idx) + self._last_brightness = int(self._settings.value("last_brightness", 100)) + self._last_dew = int(self._settings.value("last_dew", 0)) + self.slider_bright.set_value_silent(self._last_brightness) + self.slider_dew.set_value_silent(self._last_dew) + + def _save_settings(self): + self._settings.setValue("mode", "usb" if self.rb_usb.isChecked() else "wifi") + self._settings.setValue("host", self.le_host.text()) + self._settings.setValue("port", self.sb_port.value()) + self._settings.setValue("serial_port", self.cb_serial.currentText()) + self._settings.setValue("last_brightness", self.slider_bright.value()) + self._settings.setValue("last_dew", self.slider_dew.value()) + + # ── UI helpers ──────────────────────────────────────────────────────────── + + def _mode_changed(self): + usb = self.rb_usb.isChecked() + self._wifi_widget.setVisible(not usb) + self._usb_widget.setVisible(usb) + + def _refresh_ports(self): + prev = self.cb_serial.currentText() + self.cb_serial.clear() + ports = [p.device for p in serial.tools.list_ports.comports()] + self.cb_serial.addItems(ports if ports else ["(no ports found)"]) + idx = self.cb_serial.findText(prev) + if idx >= 0: + self.cb_serial.setCurrentIndex(idx) + + def _set_controls_enabled(self, enabled: bool): + for w in (self.btn_open, self.btn_close, self.btn_halt, + self.btn_cal_on, self.btn_cal_off, + self.btn_dew_on, self.btn_dew_off, + self.slider_bright, self.slider_dew): + w.setEnabled(enabled) + + # ── Connection ──────────────────────────────────────────────────────────── + + def _on_connect_toggle(self): + if self._backend is not None: + self._disconnect() + else: + self._connect() + + def _connect(self): + self.btn_connect.setEnabled(False) + self.status_bar.showMessage("Connecting…") + + def worker(): + try: + if self.rb_usb.isChecked(): + b = AlnitakBackend(self.cb_serial.currentText()) + else: + b = AlpacaBackend(self.le_host.text(), self.sb_port.value()) + b.connect() + return b + except Exception as exc: + return exc + + def done(fut): + result = fut.result() + if isinstance(result, Exception): + self._log_signal.emit(f"Connection failed: {result}") + self.status_bar.showMessage("Connection failed") + self.btn_connect.setEnabled(True) + return + self._backend = result + self._save_settings() + self._poller = PollWorker(self._backend) + self._poller.state_ready.connect(self._state_signal) + self._poller.poll_error.connect(self._err_signal) + self._poller.start() + self.btn_connect.setText("Disconnect") + self.btn_connect.setEnabled(True) + self._set_controls_enabled(True) + mode = "USB/Alnitak" if self.rb_usb.isChecked() else f"WiFi ({self.le_host.text()})" + self._log_signal.emit(f"Connected via {mode}") + self.status_bar.showMessage(f"Connected – {mode}") + + self._pool.submit(worker).add_done_callback( + lambda f: QTimer.singleShot(0, lambda: done(f)) + ) + + def _disconnect(self): + if self._poller: + self._poller.stop() + self._poller = None + if self._backend: + try: + self._backend.disconnect() + except Exception: + pass + self._backend = None + self.btn_connect.setText("Connect") + self._set_controls_enabled(False) + self.lbl_conn_status.set_state(1) + self.lbl_cover.set_state(1, COVER_LABELS) + self.lbl_cal.set_state(1, CAL_LABELS) + self.status_bar.showMessage("Disconnected") + self._log_signal.emit("Disconnected.") + + # ── Commands ────────────────────────────────────────────────────────────── + + def _run(self, fn, *args, msg=""): + """Submit a backend command to the thread pool, log result.""" + if self._backend is None: + return + + def worker(): + try: + fn(*args) + if msg: + self._log_signal.emit(msg) + except Exception as e: + self._log_signal.emit(f"ERROR: {e}") + + self._pool.submit(worker) + + def _do_open_cover(self): + self._run(self._backend.open_cover, msg="→ Open cover") + + def _do_close_cover(self): + self._run(self._backend.close_cover, msg="→ Close cover") + + def _do_halt_cover(self): + self._run(self._backend.halt_cover, msg="→ Halt cover") + + def _do_cal_on(self): + b = self.slider_bright.value() + self._last_brightness = b + self._run(self._backend.calibrator_on, b, msg=f"→ Light ON brightness={b}") + + def _do_cal_off(self): + self._run(self._backend.calibrator_off, msg="→ Light OFF") + + def _brightness_committed(self, v: int): + self._last_brightness = v + self._run(self._backend.calibrator_on, v, msg=f"→ Brightness set to {v}") + + def _do_dew_on(self): + pct = max(self._last_dew, self.slider_dew.value()) or 50 + self.slider_dew.set_value_silent(pct) + self._dew_on = True + self._run(self._backend.set_dew, pct, msg=f"→ Dew heater ON {pct}%") + + def _do_dew_off(self): + self._last_dew = self.slider_dew.value() + self._dew_on = False + self.slider_dew.set_value_silent(0) + self._run(self._backend.set_dew, 0, msg="→ Dew heater OFF") + + def _dew_committed(self, v: int): + self._last_dew = v if v > 0 else self._last_dew + self._run(self._backend.set_dew, v, msg=f"→ Dew heater {v}%") + + # ── State update (called on GUI thread via signal) ──────────────────────── + + def _apply_state(self, state: DeviceState): + self.lbl_conn_status.set_state(3, {3: ("CONNECTED", "#4caf50")}) + self.lbl_cover.set_state(state.cover, COVER_LABELS) + self.lbl_cal.set_state(state.calibrator, CAL_LABELS) + self.slider_bright.set_value_silent(state.brightness) + + dew = state.dew + colour = "#ff7043" if dew > 0 else "#9e9e9e" + self.lbl_dew.setText( + f'' + f' {"ON" if dew > 0 else "OFF"} {dew}%' + ) + self.slider_dew.set_value_silent(dew) + + def _on_poll_error(self, msg: str): + self.status_bar.showMessage(f"Poll error: {msg}") + + # ── Log ────────────────────────────────────────────────────────────────── + + def _append_log(self, text: str): + ts = datetime.now().strftime("%H:%M:%S") + self.log_view.appendPlainText(f"[{ts}] {text}") + + # ── Cleanup ─────────────────────────────────────────────────────────────── + + def closeEvent(self, event): + self._save_settings() + self._disconnect() + self._pool.shutdown(wait=False) + super().closeEvent(event) + + +# ───────────────────────────────────────────────────────────────────────────── +# Dark theme +# ───────────────────────────────────────────────────────────────────────────── + +def apply_dark_theme(app: QApplication): + app.setStyle("Fusion") + pal = QPalette() + C = QColor + pal.setColor(QPalette.ColorRole.Window, C(26, 26, 26)) + pal.setColor(QPalette.ColorRole.WindowText, C(224, 224, 224)) + pal.setColor(QPalette.ColorRole.Base, C(38, 38, 38)) + pal.setColor(QPalette.ColorRole.AlternateBase, C(45, 45, 45)) + pal.setColor(QPalette.ColorRole.ToolTipBase, C(50, 50, 50)) + pal.setColor(QPalette.ColorRole.ToolTipText, C(220, 220, 220)) + pal.setColor(QPalette.ColorRole.Text, C(224, 224, 224)) + pal.setColor(QPalette.ColorRole.Button, C(50, 50, 50)) + pal.setColor(QPalette.ColorRole.ButtonText, C(224, 224, 224)) + pal.setColor(QPalette.ColorRole.BrightText, C(255, 255, 255)) + pal.setColor(QPalette.ColorRole.Link, C(66, 165, 245)) + pal.setColor(QPalette.ColorRole.Highlight, C(58, 122, 200)) + pal.setColor(QPalette.ColorRole.HighlightedText, C(255, 255, 255)) + pal.setColor(QPalette.ColorGroup.Disabled, + QPalette.ColorRole.ButtonText, C(100, 100, 100)) + pal.setColor(QPalette.ColorGroup.Disabled, + QPalette.ColorRole.Text, C(100, 100, 100)) + app.setPalette(pal) + app.setStyleSheet(""" + QGroupBox { + border: 1px solid #404040; + border-radius: 6px; + margin-top: 10px; + padding-top: 6px; + font-weight: bold; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 4px; + color: #90caf9; + } + QSlider::groove:horizontal { + height: 6px; + background: #404040; + border-radius: 3px; + } + QSlider::handle:horizontal { + background: #3a7ac8; + width: 16px; + height: 16px; + border-radius: 8px; + margin: -5px 0; + } + QSlider::sub-page:horizontal { + background: #3a7ac8; + border-radius: 3px; + } + QLineEdit, QSpinBox, QComboBox, QPlainTextEdit { + background: #2a2a2a; + border: 1px solid #505050; + border-radius: 4px; + padding: 3px; + } + QPushButton { + border-radius: 4px; + padding: 5px 12px; + } + QPushButton:default { + background-color: #3a7ac8; + color: white; + } + """) + + +# ───────────────────────────────────────────────────────────────────────────── +# Entry point +# ───────────────────────────────────────────────────────────────────────────── + +def main(): + app = QApplication(sys.argv) + app.setApplicationName("ESP32 FlatPanel Controller") + app.setOrganizationName("FlatPanel") + apply_dark_theme(app) + win = MainWindow() + win.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/controller/requirements.txt b/controller/requirements.txt new file mode 100644 index 0000000..aaae852 --- /dev/null +++ b/controller/requirements.txt @@ -0,0 +1,3 @@ +PyQt6>=6.4.0 +requests>=2.28.0 +pyserial>=3.5 diff --git a/docs/BUILD_NOTES.md b/docs/BUILD_NOTES.md new file mode 100644 index 0000000..bd7e743 --- /dev/null +++ b/docs/BUILD_NOTES.md @@ -0,0 +1,289 @@ +# ESP32 FlatPanel — Build Notes + +## Bill of Materials + +| # | Item | Qty | Notes | +|---|------|-----|-------| +| 1 | Arduino Nano ESP32 | 1 | ESP32-S3 based, USB-C | +| 2 | MG995 servo | 1 | Cover open/close (D9) | +| 3 | SG90 micro servo | 1 | Spare / future use | +| 4 | 28BYJ-48 stepper | 1 | Spare / future use | +| 5 | IRLZ44N N-ch MOSFET | 2 | Logic-level; one for LED, one for dew heater | +| 6 | 100 Ω resistor ¼W | 2 | Gate series resistors | +| 7 | 10 kΩ resistor ¼W | 2 | Gate pull-downs | +| 8 | LM2596 buck converter module | 1 | 12V → 5V for ESP32 + servo | +| 9 | 12V DC power supply ≥ 4A | 1 | LED 2A + dew 0.5A + servo 1A peaks | +| 10 | White LED strip 12V (high-CRI ≥ Ra90) | 1 | Length to match aperture | +| 11 | Frosted acrylic sheet 3mm | 1 | Cut to telescope OD | +| 12 | Resistive heating tape / nichrome mat 12V | 1 | ~24 Ω for 6W; see sizing table below | +| 13 | Stripboard / perfboard | 1 | For wiring the MOSFETs and resistors | +| 14 | JST-XH connectors or screw terminals | set | For detachable wiring | +| 15 | Heat shrink tubing assorted | pack | For all soldered joints | +| 16 | Kapton tape 10mm | 1 roll | Securing heater element to acrylic | +| 17 | M2/M3 screws, standoffs | set | Mounting ESP32 and stripboard | +| 18 | 1N4007 diode (optional) | 1 | Reverse polarity protection on 12V input | +| 19 | Multimeter | 1 | **Required** for LM2596 calibration | + +### Dew heater element sizing + +| Resistance | Power @ 12V | Suitable for | +|------------|-------------|--------------| +| 48 Ω | 3 W | Refractor up to 80mm | +| 24 Ω | 6 W | Refractor 80–150mm (**recommended starting point**) | +| 16 Ω | 9 W | Newtonians 150–250mm | +| 12 Ω | 12 W | Large apertures 250mm+ | + +> PWM control means the average power is `P × (percent/100)`, so it is fine +> to overspec the element and run it at 30–50%. + +--- + +## Tools Required + +- Soldering iron + solder (60/40 or lead-free) +- Wire strippers and cutters +- Multimeter (essential for LM2596 setup) +- Hot glue gun or epoxy (for mechanical parts) +- Small Phillips screwdriver +- Isopropyl alcohol + cotton swabs (flux cleanup) +- PlatformIO CLI or IDE for firmware flashing + +--- + +## Step-by-Step Build Instructions + +### Phase 1 — LM2596 Calibration ⚠ Do this FIRST before connecting anything else + +1. Connect **only** the 12V PSU to the LM2596 `IN+` and `IN−` terminals. +2. Set your multimeter to DC voltage, probes on `OUT+` and `OUT−`. +3. Adjust the LM2596 trimmer pot (small brass screw): + - Clockwise → voltage **increases** + - Counter-clockwise → voltage **decreases** +4. Dial to **5.00 V ± 0.05 V**. +5. Disconnect the PSU. The buck is now calibrated and safe to use with the ESP32 and servo. + +> Connecting the MG995 to an unregulated or high-voltage supply will burn out +> the servo electronics instantly. + +--- + +### Phase 2 — Stripboard Layout + +Recommended wire colour convention: + +| Colour | Rail | +|--------|------| +| Red | 12 V positive | +| Orange | 5 V positive | +| Black | GND | +| Blue | PWM signal | +| Yellow | Servo signal | +| Grey | Load output (LED−, Heater−) | + +Lay three horizontal bus strips along the top of the board: +- `12V` bus (red) +- `5V` bus (orange) +- `GND` bus (black) + +Place both MOSFETs at the bottom edge of the board, flat faces to the right, legs bent 90° and inserted vertically: + +``` +IRLZ44N (flat face → you) + Pin 1: Gate ← 100Ω from ESP32 PWM, 10kΩ to GND + Pin 2: Drain → load negative (LED− or Heater−) + Pin 3: Source → GND bus +``` + +--- + +### Phase 3 — MOSFET-A (LED Panel) Wiring + +1. Insert MOSFET-A into the stripboard. +2. Solder a 100 Ω resistor between **Gate** and the pad that will connect to **D3** on the ESP32. +3. Solder a 10 kΩ resistor between **Gate** and the **GND bus**. +4. Solder **Source** to the **GND bus**. +5. Run a wire from **Drain** to the negative lead of the LED strip (mark it grey/black). +6. Solder a wire from the positive lead of the LED strip to the **12V bus**. + +> Test at this point: apply a 3.3V signal to D3 connection. The LED strip should illuminate. +> If it stays on at 0V, check the 10kΩ pull-down is correctly placed Gate→GND. + +--- + +### Phase 4 — MOSFET-B (Dew Heater) Wiring + +Repeat Phase 3 for MOSFET-B, using **D5** instead of D3, and the heating element instead of the LED strip. + +The heater element positive lead connects to the 12V bus; negative to MOSFET-B drain. + +--- + +### Phase 5 — MG995 Servo Wiring + +The MG995 uses a standard 3-wire connector: + +| Wire colour | Connection | +|-------------|------------| +| Brown or Black | GND bus | +| Red | **5V bus** (from LM2596 — NOT 3.3V or raw 12V) | +| Orange or Yellow | D9 on ESP32 | + +> The MG995 draws up to 1A under load. Always power it from the regulated 5V +> rail. If the servo twitches or buzzes after connecting, re-check the LM2596 +> output voltage — it should not drop below 4.8V under servo load. + +--- + +### Phase 6 — ESP32 Power and Ground + +1. Run a wire from the 5V bus to **VIN** on the Arduino Nano ESP32. +2. Run a wire from the GND bus to **GND** on the ESP32. +3. Do **not** power the ESP32 from the 3.3V pin — that is an output, not an input. + +--- + +### Phase 7 — Firmware Configuration and Flash + +1. Open `firmware/src/config.h` and edit: + ```cpp + #define WIFI_SSID "your_network_name" + #define WIFI_PASSWORD "your_password" + ``` +2. Tune servo angles (power the servo and watch physical movement): + ```cpp + #define SERVO_CLOSED 0 // panel in flat position + #define SERVO_OPEN 90 // panel clear of telescope + ``` +3. For USB-only operation (no WiFi debug output on the serial line): + ```cpp + #define SERIAL_DEBUG false + ``` +4. Flash via PlatformIO: + ``` + cd flatpanel + pio run -t upload + ``` + +--- + +### Phase 8 — First Power-On Checks (ordered sequence) + +Work through this list in order. Stop if any step fails. + +**Step 1 — Bench test buck converter** +- Measure 5V bus at LM2596 OUT+ with no loads connected. +- Must read 4.9–5.1V. Adjust if needed. + +**Step 2 — ESP32 power** +- Connect ESP32 to 5V bus and GND bus only. +- Open a serial monitor at 9600 baud. You should see the boot message and a WiFi IP address printed. +- If nothing appears: check VIN wiring. + +**Step 3 — WiFi / Alpaca API verification** +- Open a web browser and navigate to: `http://:11111/management/v1/configureddevices` +- You should see a JSON response including `"DeviceType":"CoverCalibrator"`. + +**Step 4 — Servo test** +- Using a serial terminal at 9600 baud, send: `>O000` (open cover command) +- You should hear the servo move and receive: `*O19000` +- Send `>C000` to close. Adjust `SERVO_OPEN`/`SERVO_CLOSED` angles and reflash if needed. + +**Step 5 — LED panel test** +- Send: `>B100` then `>L000` +- The LED strip should light up at ~39% brightness. +- Measure the voltage at MOSFET-A Drain — it should be near 0V when the LED is on (the load pulls it low). + +**Step 6 — Dew heater test** +- Send: `>T050` (50% power) +- Wait 30 seconds. The heater element should be noticeably warm to the touch. +- Send: `>T000` to turn off. + +**Step 7 — Full integration test** +- Launch `controller/flatpanel_controller.py` (`pip install PyQt6 requests pyserial` first). +- Connect in WiFi mode. +- Exercise all controls: cover, brightness slider, dew heater slider. +- Confirm the log shows successful commands and the status dots update. + +--- + +### Phase 9 — Mechanical Assembly + +1. **Flat panel diffuser**: cut the frosted acrylic to the outer diameter of your telescope tube. A friction fit or 3D-printed retaining ring works well. +2. **LED strip**: arrange in a spiral or grid on the rear of the acrylic. Secure with the adhesive backing or a thin layer of silicone. +3. **Dew heater**: run the heating element in a serpentine pattern across the face of the acrylic (the side facing the telescope). Secure with Kapton tape. Do not cover the diffuser surface with tape. +4. **Cover hinge**: mount the MG995 so it rotates the panel into and out of the optical path. A 3D-printed bracket is the cleanest approach. Set `SERVO_CLOSED` so the panel seats flush against the scope aperture. +5. **Enclosure**: mount the ESP32, stripboard, and LM2596 in a weatherproof ABS enclosure on the focuser end of the scope. Use cable glands for the 12V input, USB-C, servo lead, and LED/heater wires. + +--- + +## Software Setup + +### Windows — ASCOM + +1. Download and install [ASCOM Platform 6.6+](https://ascom-standards.org/Downloads/Index.htm). +2. Download and install [ASCOM Remote](https://github.com/ASCOMInitiative/ASCOM.Remote/releases) — the Alpaca bridge. +3. In ASCOM Remote setup, add a new **CoverCalibrator** device: + - IP address: `esp32-flatpanel.local` (or the IP from the serial monitor) + - Port: `11111` + - Device number: `0` +4. In your capture software (N.I.N.A., SGPro, APT), choose **ASCOM CoverCalibrator** and select the ASCOM Remote device. + +### Linux / Raspberry Pi — INDI + +```bash +# Install dependencies +sudo apt install libindi-dev libcurl4-openssl-dev nlohmann-json3-dev cmake build-essential + +# Build the custom INDI driver +cd flatpanel/indi-driver +cmake -B build -DCMAKE_INSTALL_PREFIX=/usr +cmake --build build -j$(nproc) +sudo cmake --install build + +# Test standalone +indiserver indi_esp32_flatpanel +``` + +In **KStars / Ekos**: +- Profile Editor → add Aux device: `ESP32 FlatPanel` +- In the driver's Connection tab: select WiFi or USB mode, enter host/port or serial port. + +### FlatPanel Controller App (all platforms) + +```bash +pip install PyQt6 requests pyserial + +# Run +python flatpanel/controller/flatpanel_controller.py +``` + +- Select **WiFi** or **USB** mode with the radio buttons. +- WiFi: enter the ESP32's hostname or IP and port 11111. +- USB: pick the COM port from the dropdown (click ⟳ to refresh). +- Click **Connect**. + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| Servo twitches/buzzes constantly | 5V rail sags under load | Re-measure LM2596; verify 5V at servo connector under motion | +| LED stays on even at 0% | Gate pull-down missing | Solder 10kΩ Gate→GND on MOSFET-A | +| LED at wrong brightness or inverted | Firmware PWM inverted? | Check `LED_PWM_PIN` and `LED_PWM_CHANNEL` in config.h | +| No WiFi after flashing | Wrong SSID/password | Edit config.h, reflash | +| Alnitak `>P000` gets no response | `SERIAL_DEBUG true` corrupts line | Set `false`, reflash | +| Cover angle wrong | SERVO_OPEN/CLOSED values wrong | Tune in config.h, reflash | +| Dew heater not warming | R too high, or MOSFET-B wiring issue | Measure Drain voltage with heater on; should be near 0V | +| Python app can't find serial port | Driver not loaded (Windows) | Install CH340 or CP210x driver for ESP32 USB | +| Connection refused on port 11111 | ESP32 not on WiFi yet | Check serial monitor for boot messages | +| INDI driver build fails | Missing libindi headers | `sudo apt install libindi-dev` | + +--- + +## Revision History + +| Version | Date | Change | +|---------|------|--------| +| 1.0 | 2026-05-17 | Initial release: WiFi/Alpaca, servo, LED PWM | +| 1.1 | 2026-05-17 | Added USB/Alnitak mode, 12V dew heater, Python controller app | diff --git a/docs/system-diagram.svg b/docs/system-diagram.svg new file mode 100644 index 0000000..19ba8d5 --- /dev/null +++ b/docs/system-diagram.svg @@ -0,0 +1,282 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + ESP32 FlatPanel — System Architecture + + + + + + + + PC Software + + + + + + N.I.N.A. / SGPro / KStars + + + via ASCOM Remote / INDI driver + + + + + + FlatPanel Controller + + + flatpanel_controller.py + + + + + Windows / Linux / macOS + + + + + + + + Arduino Nano ESP32 + + + + + WiFi • USB CDC + + + + + Alpaca REST :11111 + + + Alnitak serial 9600 baud + + + + + D9 → servo PWM + + + D3 → LED brightness PWM + + + D5 → dew heater PWM + + + VIN ← 5V (LM2596) + + + GND → common ground + + + + + + + + + + MG995 Servo + + + Cover open / close mechanism + + + 5V supply via LM2596 + + + + + + IRLZ44N MOSFET-A + + + → White LED Panel (12V) + + + PWM brightness 0–255 + + + + + + IRLZ44N MOSFET-B + + + → Dew Heater Element (12V) + + + PWM power 0–100 % + + + + + + 12V PSU (≥4A) + + + → 12V rail: LED + dew heater + + + → LM2596 buck → 5V rail + + + → 5V: ESP32 VIN + servo + + + + + + + + + + HTTP / Alpaca + + + WiFi :11111 + + + + + + USB / Alnitak + + + 9600 baud serial + + + + + + + + + D9 + + + + D3 PWM + + + + D5 PWM + + + + 5V VIN + + + + + + Legend + + + WiFi / Alpaca (HTTP) + + + USB / Alnitak (serial) + + + GPIO / PWM signal + + + Power supply (5V) + + + Both WiFi and USB active simultaneously + + + + + ESP32 FlatPanel v1.1 — github.com / your-repo + + + diff --git a/docs/wiring-diagram.svg b/docs/wiring-diagram.svg new file mode 100644 index 0000000..1bb0622 --- /dev/null +++ b/docs/wiring-diagram.svg @@ -0,0 +1,313 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ESP32 FlatPanel — Wiring Diagram + + + Wire colours: red=12V orange=5V black=GND blue=PWM signal yellow=servo signal grey=load output + + + + + + + 12V PSU + ≥ 4A + + 12V+ + GND + 12V+ + + + + LM2596 Buck + Set to 5.0V first! + IN+ + IN− + OUT+ + OUT− + + + + Arduino Nano ESP32 + + D9 → servo + D3 → LED MOSFET gate + D5 → Dew MOSFET gate + + VIN ← 5V + GND ← common GND + + USB-C → PC + (Alnitak 9600 baud) + WiFi → Alpaca :11111 + + + + + + + + + USB-C + + + + MG995 Servo + Cover mechanism + GND + 5V + Signal + + + + IRLZ44N + MOSFET-A (LED) + Gate – Drain – Source + Gate + Drain + Source + + + + IRLZ44N + MOSFET-B (Dew) + Gate – Drain – Source + Gate + Drain + Source + + + + LED Panel (12V) + White high-CRI strip + + frosted acrylic + LED+ + LED− + + + + Dew Heater + Nichrome / heating tape + ~24Ω 6W @ 12V + H+ + H− + + + + + + + + + + GND bus + + + + + + 5V + + + + + + + + + + + + + + + + + + + + + + + + + + + + D9 servo + + + + + + + + 100R + + + + + + + 10k + D3 + + + + + + + 100R + + + + + + 10k + D5 + + + + + 12V rail + + + + + + + + + + + + + + + LED− (via MOSFET-A drain) + + + + + + + H− (via MOSFET-B drain) + + + + Wire Legend + + + + 12V positive + + + + 5V positive + + + + GND / ground + + + + PWM signal + + + + Servo signal + + + + Load output + + + + Resistor + + + + + ⚠ Build Order + + + 1. Set LM2596 to 5.0V BEFORE connecting servo or ESP32 + + + 2. Add 10kΩ Gate→GND pull-downs on both MOSFETs + + + 3. Add 100Ω Gate series resistors on both MOSFETs + + + 4. Test LED MOSFET with 5V bench supply before 12V + + + 5. Flash firmware & verify via Serial before full assembly + + + MOSFET IRLZ44N pinout (flat face towards you): Gate | Drain | Source + + + + + Not a certified schematic — verify all connections before powering on. v1.1 + + + diff --git a/firmware/platformio.ini b/firmware/platformio.ini new file mode 100644 index 0000000..0851f77 --- /dev/null +++ b/firmware/platformio.ini @@ -0,0 +1,14 @@ +[env:arduino_nano_esp32] +platform = espressif32 +board = arduino_nano_esp32 +framework = arduino + +upload_speed = 921600 +monitor_speed = 115200 + +lib_deps = + ; More actively maintained forks of AsyncTCP / AsyncWebServer + esphome/AsyncTCP-esphome @ ^2.1.4 + esphome/ESPAsyncWebServer-esphome @ ^3.2.2 + bblanchon/ArduinoJson @ >=6.21.0 <7.0.0 + madhephaestus/ESP32Servo @ ^0.13.0 diff --git a/firmware/src/config.h b/firmware/src/config.h new file mode 100644 index 0000000..1e02acd --- /dev/null +++ b/firmware/src/config.h @@ -0,0 +1,58 @@ +#pragma once + +// --------------------------------------------------------------------------- +// WiFi +// --------------------------------------------------------------------------- +#define WIFI_SSID "your_wifi_ssid" +#define WIFI_PASSWORD "your_wifi_password" +#define HOSTNAME "esp32-flatpanel" // accessible as esp32-flatpanel.local + +// --------------------------------------------------------------------------- +// Pin assignments (Arduino Nano ESP32 D-pin numbers) +// --------------------------------------------------------------------------- +#define SERVO_PIN 9 // MG995 signal wire → D9 +#define LED_PWM_PIN 3 // MOSFET gate → D3 + +// --------------------------------------------------------------------------- +// Servo angles (degrees) – tune to your mechanical stop positions +// --------------------------------------------------------------------------- +#define SERVO_CLOSED 0 // panel in image path (cover closed / panel active) +#define SERVO_OPEN 90 // panel rotated clear of telescope + +// Time (ms) to wait for the MG995 to reach its target before marking done. +// MG995 spec: ~0.2 s / 60° no-load; 1 500 ms covers 90° + margin. +#define COVER_MOVE_TIMEOUT_MS 1500 + +// --------------------------------------------------------------------------- +// LED PWM (LEDC channel 0, 8-bit → 0-255) +// --------------------------------------------------------------------------- +#define LED_PWM_CHANNEL 0 +#define LED_PWM_FREQ 5000 // 5 kHz – above visible flicker +#define LED_PWM_RESOLUTION 8 +#define MAX_BRIGHTNESS 255 + +// --------------------------------------------------------------------------- +// Dew heater (12 V via N-channel MOSFET, same circuit as LED) +// --------------------------------------------------------------------------- +#define DEW_HEATER_PIN 5 // MOSFET gate → D5 +#define DEW_PWM_CHANNEL 1 // LEDC channel 1 (channel 0 used for LED) +#define DEW_PWM_FREQ 1000 // 1 kHz – fine for resistive heating element +#define DEW_PWM_RESOLUTION 8 // 8-bit → 0-255 maps to 0-100 % + +// --------------------------------------------------------------------------- +// USB / Alnitak serial protocol +// Set SERIAL_DEBUG to false when using USB mode so the Alnitak driver +// receives only properly-formatted responses. Debug output is harmless in +// WiFi-only mode because Alnitak parsers ignore lines not starting with '*'. +// --------------------------------------------------------------------------- +#define SERIAL_DEBUG true +// Device type 19 = Flip-Flat (cover + calibrator). Recognised natively by +// N.I.N.A., SGPro, APT, and the INDI indi_flipflat driver. +#define ALNITAK_DEVICE_ID 19 + +// --------------------------------------------------------------------------- +// ASCOM Alpaca +// --------------------------------------------------------------------------- +#define ALPACA_PORT 11111 +// Generate a fresh UUID at https://www.uuidgenerator.net/ and paste it here. +#define DEVICE_UUID "a2ba5c7e-4b3f-4f98-a7d0-1c5b8e2f9d3a" diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp new file mode 100644 index 0000000..3efdc27 --- /dev/null +++ b/firmware/src/main.cpp @@ -0,0 +1,515 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "config.h" + +// --------------------------------------------------------------------------- +// Conditional debug output – disabled in USB/Alnitak mode to keep the serial +// line clean for the Alnitak protocol parser. +// --------------------------------------------------------------------------- +#if SERIAL_DEBUG + #define DBG(...) Serial.print(__VA_ARGS__) + #define DBGF(...) Serial.printf(__VA_ARGS__) + #define DBGLN(...) Serial.println(__VA_ARGS__) +#else + #define DBG(...) + #define DBGF(...) + #define DBGLN(...) +#endif + +// --------------------------------------------------------------------------- +// ASCOM Alpaca state enumerations +// --------------------------------------------------------------------------- +enum CalibratorStatus : int { + CalNotPresent = 0, + CalOff = 1, + CalNotReady = 2, + CalReady = 3, + CalUnknown = 4, + CalError = 5 +}; + +enum CoverStatus : int { + CovNotPresent = 0, + CovUnknown = 1, + CovOpen = 2, + CovClosed = 3, + CovMoving = 4, + CovError = 5 +}; + +// --------------------------------------------------------------------------- +// Global device state +// --------------------------------------------------------------------------- +static int g_brightness = 0; +static int g_dewPercent = 0; +static CalibratorStatus g_calState = CalOff; +static CoverStatus g_covState = CovClosed; +static bool g_connected = false; +static uint32_t g_txID = 0; + +// Cover-movement state machine +static CoverStatus g_moveTarget = CovClosed; +static unsigned long g_moveStartMs = 0; +static bool g_moving = false; + +AsyncWebServer server(ALPACA_PORT); +AsyncUDP udp; +Servo coverServo; + +// --------------------------------------------------------------------------- +// Hardware helpers +// --------------------------------------------------------------------------- +static void applyBrightness(int b) { + b = constrain(b, 0, MAX_BRIGHTNESS); + g_brightness = b; + ledcWrite(LED_PWM_CHANNEL, b); + g_calState = (b > 0) ? CalReady : CalOff; +} + +static void applyDewHeater(int pct) { + g_dewPercent = constrain(pct, 0, 100); + // map 0-100 % to 0-255 PWM, with full off at 0 % and full on at 100 % + ledcWrite(DEW_PWM_CHANNEL, map(g_dewPercent, 0, 100, 0, 255)); +} + +static void startCoverMove(CoverStatus target) { + coverServo.write(target == CovOpen ? SERVO_OPEN : SERVO_CLOSED); + g_covState = CovMoving; + g_moveTarget = target; + g_moveStartMs = millis(); + g_moving = true; +} + +// --------------------------------------------------------------------------- +// Alnitak serial protocol (USB mode) +// +// Format: command >CXXX\r (C = command char, XXX = 3-digit value) +// response *CDDVVV\r (DD = 2-digit device type, VVV = 3-digit value) +// +// Standard commands understood by N.I.N.A., SGPro, APT, indi_flipflat: +// P – Ping B – Set brightness J – Get brightness +// L – Light on D – Light off +// O – Open C – Close H – Halt +// S – State (motorStatus + lightStatus) +// +// Custom extensions (for our INDI/ASCOM driver when using USB mode): +// T – Set dew heater % U – Get dew heater % +// --------------------------------------------------------------------------- +static int alnitakMotorState() { + switch (g_covState) { + case CovOpen: return 2; // Open + case CovClosed: return 1; // Closed + default: return 0; // NotOpenOrClosed / moving / unknown + } +} + +static void processAlnitak(const char* cmd) { + if (cmd[0] != '>') return; + + char ch = cmd[1]; + int val = (strlen(cmd) > 2) ? atoi(cmd + 2) : 0; + char resp[20]; + + switch (ch) { + case 'P': // Ping + snprintf(resp, sizeof(resp), "*P%02d000\r", ALNITAK_DEVICE_ID); + break; + + case 'S': // State: *SDD00 + snprintf(resp, sizeof(resp), "*S%02d%d%d00\r", + ALNITAK_DEVICE_ID, + alnitakMotorState(), + (g_calState == CalReady) ? 1 : 0); + break; + + case 'B': // Set brightness 0-255 + applyBrightness(constrain(val, 0, MAX_BRIGHTNESS)); + snprintf(resp, sizeof(resp), "*B%02d%03d\r", ALNITAK_DEVICE_ID, g_brightness); + break; + + case 'J': // Get brightness + snprintf(resp, sizeof(resp), "*J%02d%03d\r", ALNITAK_DEVICE_ID, g_brightness); + break; + + case 'L': // Light on (keep existing brightness; default 100 if zero) + if (g_brightness == 0) applyBrightness(100); + else applyBrightness(g_brightness); + snprintf(resp, sizeof(resp), "*L%02d000\r", ALNITAK_DEVICE_ID); + break; + + case 'D': // Light off + applyBrightness(0); + snprintf(resp, sizeof(resp), "*D%02d000\r", ALNITAK_DEVICE_ID); + break; + + case 'O': // Open cover + if (!g_moving) startCoverMove(CovOpen); + snprintf(resp, sizeof(resp), "*O%02d000\r", ALNITAK_DEVICE_ID); + break; + + case 'C': // Close cover + if (!g_moving) startCoverMove(CovClosed); + snprintf(resp, sizeof(resp), "*C%02d000\r", ALNITAK_DEVICE_ID); + break; + + case 'H': // Halt + if (g_moving) { + coverServo.write(coverServo.read()); + g_covState = CovUnknown; + g_moving = false; + } + snprintf(resp, sizeof(resp), "*H%02d000\r", ALNITAK_DEVICE_ID); + break; + + case 'T': // (custom) Set dew heater percent 0-100 + applyDewHeater(constrain(val, 0, 100)); + snprintf(resp, sizeof(resp), "*T%02d%03d\r", ALNITAK_DEVICE_ID, g_dewPercent); + break; + + case 'U': // (custom) Get dew heater percent + snprintf(resp, sizeof(resp), "*U%02d%03d\r", ALNITAK_DEVICE_ID, g_dewPercent); + break; + + default: + return; // unknown command – send nothing + } + + Serial.print(resp); +} + +static void handleAlnitak() { + static char buf[20]; + static uint8_t pos = 0; + + while (Serial.available()) { + char c = static_cast(Serial.read()); + if (c == '\r' || c == '\n') { + if (pos > 0) { + buf[pos] = '\0'; + processAlnitak(buf); + pos = 0; + } + } else if (pos < sizeof(buf) - 1) { + buf[pos++] = c; + } else { + pos = 0; // buffer overflow guard + } + } +} + +// --------------------------------------------------------------------------- +// Alpaca response helpers +// --------------------------------------------------------------------------- +static int clientTxID(AsyncWebServerRequest* req) { + const AsyncWebParameter* p = req->getParam("ClientTransactionID"); + if (!p) p = req->getParam("ClientTransactionID", true); + return p ? p->value().toInt() : 0; +} + +static void fillCommon(StaticJsonDocument<512>& doc, AsyncWebServerRequest* req, + int errNum = 0, const char* errMsg = "") { + doc["ClientTransactionID"] = clientTxID(req); + doc["ServerTransactionID"] = ++g_txID; + doc["ErrorNumber"] = errNum; + doc["ErrorMessage"] = errMsg; +} + +static void sendInt(AsyncWebServerRequest* req, int value) { + StaticJsonDocument<256> doc; + doc["Value"] = value; + fillCommon(doc, req); + String out; serializeJson(doc, out); + req->send(200, "application/json", out); +} + +static void sendBool(AsyncWebServerRequest* req, bool value) { + StaticJsonDocument<256> doc; + doc["Value"] = value; + fillCommon(doc, req); + String out; serializeJson(doc, out); + req->send(200, "application/json", out); +} + +static void sendStr(AsyncWebServerRequest* req, const char* value) { + StaticJsonDocument<384> doc; + doc["Value"] = value; + fillCommon(doc, req); + String out; serializeJson(doc, out); + req->send(200, "application/json", out); +} + +static void sendOK(AsyncWebServerRequest* req) { + StaticJsonDocument<192> doc; + fillCommon(doc, req); + String out; serializeJson(doc, out); + req->send(200, "application/json", out); +} + +static void sendErr(AsyncWebServerRequest* req, int errNum, const char* errMsg) { + StaticJsonDocument<256> doc; + fillCommon(doc, req, errNum, errMsg); + String out; serializeJson(doc, out); + req->send(200, "application/json", out); +} + +static void sendIntArray(AsyncWebServerRequest* req, std::initializer_list values) { + StaticJsonDocument<256> doc; + JsonArray arr = doc.createNestedArray("Value"); + for (int v : values) arr.add(v); + fillCommon(doc, req); + String out; serializeJson(doc, out); + req->send(200, "application/json", out); +} + +// --------------------------------------------------------------------------- +// Route setup +// --------------------------------------------------------------------------- +static void setupRoutes() { + + // ---- Alpaca Management API ----------------------------------------------- + + server.on("/management/apiversions", HTTP_GET, [](AsyncWebServerRequest* req) { + sendIntArray(req, {1}); + }); + + server.on("/management/v1/description", HTTP_GET, [](AsyncWebServerRequest* req) { + StaticJsonDocument<512> doc; + JsonObject val = doc.createNestedObject("Value"); + val["ServerName"] = "ESP32 FlatPanel"; + val["Manufacturer"] = "DIY"; + val["ManufacturerVersion"] = "1.0.0"; + val["Location"] = "Telescope"; + fillCommon(doc, req); + String out; serializeJson(doc, out); + req->send(200, "application/json", out); + }); + + server.on("/management/v1/configureddevices", HTTP_GET, [](AsyncWebServerRequest* req) { + StaticJsonDocument<512> doc; + JsonArray arr = doc.createNestedArray("Value"); + JsonObject dev = arr.createNestedObject(); + dev["DeviceName"] = "ESP32 FlatPanel"; + dev["DeviceType"] = "CoverCalibrator"; + dev["DeviceNumber"] = 0; + dev["UniqueID"] = DEVICE_UUID; + fillCommon(doc, req); + String out; serializeJson(doc, out); + req->send(200, "application/json", out); + }); + + // ---- CoverCalibrator – common device properties ------------------------- + + server.on("/api/v1/covercalibrator/0/name", HTTP_GET, [](AsyncWebServerRequest* req) { + sendStr(req, "ESP32 FlatPanel"); + }); + server.on("/api/v1/covercalibrator/0/description", HTTP_GET, [](AsyncWebServerRequest* req) { + sendStr(req, "Automated telescope flat panel with motorised cover and dew heater"); + }); + server.on("/api/v1/covercalibrator/0/driverinfo", HTTP_GET, [](AsyncWebServerRequest* req) { + sendStr(req, "ESP32 FlatPanel Alpaca driver v1.1"); + }); + server.on("/api/v1/covercalibrator/0/driverversion", HTTP_GET, [](AsyncWebServerRequest* req) { + sendStr(req, "1.1.0"); + }); + server.on("/api/v1/covercalibrator/0/interfaceversion", HTTP_GET, [](AsyncWebServerRequest* req) { + sendInt(req, 1); + }); + + // SetDewHeater / GetDewHeater exposed as Alpaca Actions + server.on("/api/v1/covercalibrator/0/supportedactions", HTTP_GET, [](AsyncWebServerRequest* req) { + StaticJsonDocument<256> doc; + JsonArray arr = doc.createNestedArray("Value"); + arr.add("SetDewHeater"); + arr.add("GetDewHeater"); + fillCommon(doc, req); + String out; serializeJson(doc, out); + req->send(200, "application/json", out); + }); + + server.on("/api/v1/covercalibrator/0/action", HTTP_PUT, [](AsyncWebServerRequest* req) { + const AsyncWebParameter* aP = req->getParam("Action", true); + const AsyncWebParameter* pP = req->getParam("Parameters", true); + if (!aP) { sendErr(req, 0x400, "Action parameter required"); return; } + + String action = aP->value(); + String params = pP ? pP->value() : ""; + action.toLowerCase(); + + if (action == "setdewheater") { + applyDewHeater(constrain(params.toInt(), 0, 100)); + sendStr(req, ""); + } else if (action == "getdewheater") { + sendStr(req, String(g_dewPercent).c_str()); + } else { + sendErr(req, 0x40C, "Action not implemented"); + } + }); + + // ---- CoverCalibrator – state properties (GET) ---------------------------- + + server.on("/api/v1/covercalibrator/0/connected", HTTP_GET, [](AsyncWebServerRequest* req) { + sendBool(req, g_connected); + }); + server.on("/api/v1/covercalibrator/0/brightness", HTTP_GET, [](AsyncWebServerRequest* req) { + sendInt(req, g_brightness); + }); + server.on("/api/v1/covercalibrator/0/maxbrightness", HTTP_GET, [](AsyncWebServerRequest* req) { + sendInt(req, MAX_BRIGHTNESS); + }); + server.on("/api/v1/covercalibrator/0/calibratorstate", HTTP_GET, [](AsyncWebServerRequest* req) { + sendInt(req, static_cast(g_calState)); + }); + server.on("/api/v1/covercalibrator/0/coverstate", HTTP_GET, [](AsyncWebServerRequest* req) { + sendInt(req, static_cast(g_covState)); + }); + + // Non-standard but polled by our Python controller and INDI driver + server.on("/api/v1/covercalibrator/0/dewheater", HTTP_GET, [](AsyncWebServerRequest* req) { + sendInt(req, g_dewPercent); + }); + server.on("/api/v1/covercalibrator/0/dewheater", HTTP_PUT, [](AsyncWebServerRequest* req) { + const AsyncWebParameter* p = req->getParam("Percentage", true); + if (!p) { sendErr(req, 0x400, "Percentage parameter required"); return; } + applyDewHeater(constrain(p->value().toInt(), 0, 100)); + sendOK(req); + }); + + // ---- CoverCalibrator – commands (PUT) ------------------------------------ + + server.on("/api/v1/covercalibrator/0/connected", HTTP_PUT, [](AsyncWebServerRequest* req) { + const AsyncWebParameter* p = req->getParam("Connected", true); + if (p) { + String v = p->value(); v.toLowerCase(); + g_connected = (v == "true"); + } + sendOK(req); + }); + + server.on("/api/v1/covercalibrator/0/calibratoron", HTTP_PUT, [](AsyncWebServerRequest* req) { + const AsyncWebParameter* p = req->getParam("Brightness", true); + if (!p) { sendErr(req, 0x400, "Brightness parameter required"); return; } + int b = p->value().toInt(); + if (b < 0 || b > MAX_BRIGHTNESS) { sendErr(req, 0x401, "Brightness out of range 0-255"); return; } + applyBrightness(b); + sendOK(req); + }); + + server.on("/api/v1/covercalibrator/0/calibratoroff", HTTP_PUT, [](AsyncWebServerRequest* req) { + applyBrightness(0); + sendOK(req); + }); + + server.on("/api/v1/covercalibrator/0/opencover", HTTP_PUT, [](AsyncWebServerRequest* req) { + if (g_covState != CovOpen && !g_moving) startCoverMove(CovOpen); + sendOK(req); + }); + + server.on("/api/v1/covercalibrator/0/closecover", HTTP_PUT, [](AsyncWebServerRequest* req) { + if (g_covState != CovClosed && !g_moving) startCoverMove(CovClosed); + sendOK(req); + }); + + server.on("/api/v1/covercalibrator/0/haltcover", HTTP_PUT, [](AsyncWebServerRequest* req) { + if (g_moving) { + coverServo.write(coverServo.read()); + g_covState = CovUnknown; + g_moving = false; + } + sendOK(req); + }); + + server.onNotFound([](AsyncWebServerRequest* req) { + req->send(404, "text/plain", "Not found"); + }); +} + +// --------------------------------------------------------------------------- +// Alpaca UDP discovery (port 32227) +// Respond to "alpacadiscovery1" broadcasts with {"AlpacaPort":11111} +// --------------------------------------------------------------------------- +static void setupDiscovery() { + if (udp.listen(32227)) { + udp.onPacket([](AsyncUDPPacket pkt) { + if (pkt.length() == 16 && + memcmp(pkt.data(), "alpacadiscovery1", 16) == 0) { + String resp = "{\"AlpacaPort\":" + String(ALPACA_PORT) + "}"; + pkt.print(resp); + } + }); + } +} + +// --------------------------------------------------------------------------- +// Arduino entry points +// --------------------------------------------------------------------------- +void setup() { + Serial.begin(9600); // 9600 matches indi_flipflat / N.I.N.A. default for Alnitak + DBGLN("\nESP32 FlatPanel booting…"); + + // Servo – start in closed position + coverServo.attach(SERVO_PIN); + coverServo.write(SERVO_CLOSED); + delay(500); + + // LED PWM (LEDC ch 0) + ledcSetup(LED_PWM_CHANNEL, LED_PWM_FREQ, LED_PWM_RESOLUTION); + ledcAttachPin(LED_PWM_PIN, LED_PWM_CHANNEL); + ledcWrite(LED_PWM_CHANNEL, 0); + + // Dew heater PWM (LEDC ch 1, 12 V via MOSFET – off at boot) + ledcSetup(DEW_PWM_CHANNEL, DEW_PWM_FREQ, DEW_PWM_RESOLUTION); + ledcAttachPin(DEW_HEATER_PIN, DEW_PWM_CHANNEL); + ledcWrite(DEW_PWM_CHANNEL, 0); + + // WiFi + WiFi.setHostname(HOSTNAME); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + DBGF("Connecting to WiFi"); + unsigned long t0 = millis(); + while (WiFi.status() != WL_CONNECTED) { + if (millis() - t0 > 30000) { DBGLN("\nWiFi timeout – restarting"); ESP.restart(); } + delay(500); + DBG('.'); + } + DBGF("\nIP: %s\n", WiFi.localIP().toString().c_str()); + + // mDNS (_alpaca._tcp) + if (MDNS.begin(HOSTNAME)) { + MDNS.addService("alpaca", "tcp", ALPACA_PORT); + MDNS.addServiceTxt("alpaca", "tcp", "txtvers", "1"); + DBGF("mDNS: http://%s.local:%d\n", HOSTNAME, ALPACA_PORT); + } + + setupRoutes(); + setupDiscovery(); + server.begin(); + DBGLN("Alpaca server started. USB/Alnitak also active on Serial."); +} + +void loop() { + // USB Alnitak protocol – always active regardless of SERIAL_DEBUG + handleAlnitak(); + + // Cover movement state machine + if (g_moving && (millis() - g_moveStartMs >= COVER_MOVE_TIMEOUT_MS)) { + g_covState = g_moveTarget; + g_moving = false; + DBGF("Cover reached: %s\n", g_covState == CovOpen ? "OPEN" : "CLOSED"); + } + + // WiFi watchdog + static unsigned long lastWifiCheck = 0; + if (millis() - lastWifiCheck > 10000) { + lastWifiCheck = millis(); + if (WiFi.status() != WL_CONNECTED) { + DBGLN("WiFi lost – reconnecting"); + WiFi.reconnect(); + } + } +} diff --git a/indi-driver/CMakeLists.txt b/indi-driver/CMakeLists.txt new file mode 100644 index 0000000..1eb60f2 --- /dev/null +++ b/indi-driver/CMakeLists.txt @@ -0,0 +1,39 @@ +cmake_minimum_required(VERSION 3.16) +project(indi_esp32_flatpanel CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Find required packages +find_package(INDI REQUIRED) +find_package(CURL REQUIRED) + +# nlohmann/json – header-only, installed via `apt install nlohmann-json3-dev` +# or fetched automatically below if not found +find_package(nlohmann_json QUIET) +if(NOT nlohmann_json_FOUND) + include(FetchContent) + FetchContent_Declare(json + URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz) + FetchContent_MakeAvailable(json) +endif() + +include_directories(${INDI_INCLUDE_DIR}) + +add_executable(indi_esp32_flatpanel + indi_esp32_flatpanel.cpp +) + +target_link_libraries(indi_esp32_flatpanel + ${INDI_LIBRARIES} + CURL::libcurl + nlohmann_json::nlohmann_json + pthread +) + +# Install binary and XML descriptor +install(TARGETS indi_esp32_flatpanel + RUNTIME DESTINATION ${CMAKE_INSTALL_PREFIX}/bin) + +install(FILES indi_esp32_flatpanel.xml + DESTINATION ${INDI_DATA_DIR}) diff --git a/indi-driver/indi_esp32_flatpanel.cpp b/indi-driver/indi_esp32_flatpanel.cpp new file mode 100644 index 0000000..422996c --- /dev/null +++ b/indi-driver/indi_esp32_flatpanel.cpp @@ -0,0 +1,581 @@ +#include "indi_esp32_flatpanel.h" + +#include +#include +#include + +// POSIX serial port (USB/Alnitak mode) +#include +#include +#include +#include + +#include +#include + +using json = nlohmann::json; + +// Alpaca CoverCalibrator cover-state values +static constexpr int COVER_OPEN = 2; +static constexpr int COVER_CLOSED = 3; +static constexpr int COVER_MOVING = 4; + +// Alpaca calibrator-state values +static constexpr int CAL_OFF = 1; +static constexpr int CAL_READY = 3; + +// Alnitak device ID embedded in responses (19 = Flip-Flat) +static constexpr int ALNITAK_DEVID = 19; + +// --------------------------------------------------------------------------- +// Registration / factory +// --------------------------------------------------------------------------- +static std::unique_ptr g_device; + +void ISGetProperties(const char* dev) { g_device->ISGetProperties(dev); } +void ISNewSwitch(const char* dev, const char* name, ISState* s, char* n[], int num) + { g_device->ISNewSwitch(dev, name, s, n, num); } +void ISNewText(const char* dev, const char* name, char* t[], char* n[], int num) + { g_device->ISNewText(dev, name, t, n, num); } +void ISNewNumber(const char* dev, const char* name, double v[], char* n[], int num) + { g_device->ISNewNumber(dev, name, v, n, num); } +void ISNewBLOB(const char* dev, const char* name, int s, int b, + char* f[], char* n[], int num) + { INDI_UNUSED(dev); INDI_UNUSED(name); INDI_UNUSED(s); INDI_UNUSED(b); + INDI_UNUSED(f); INDI_UNUSED(n); INDI_UNUSED(num); } +void ISSnoopDevice(XMLEle* root) { g_device->ISSnoopDevice(root); } + +int main(int /*argc*/, char* /*argv*/[]) { + curl_global_init(CURL_GLOBAL_ALL); + g_device = std::make_unique(); + g_device->ISGetProperties(nullptr); + return 0; +} + +// --------------------------------------------------------------------------- +// Constructor / destructor +// --------------------------------------------------------------------------- +ESP32FlatPanel::ESP32FlatPanel() + : LightBoxInterface(this, true), + DustCapInterface(this) +{ + setVersion(1, 1); +} + +ESP32FlatPanel::~ESP32FlatPanel() { + closeSerial(); + curl_global_cleanup(); +} + +// --------------------------------------------------------------------------- +// DefaultDevice interface +// --------------------------------------------------------------------------- +const char* ESP32FlatPanel::getDefaultName() { + return "ESP32 FlatPanel"; +} + +bool ESP32FlatPanel::initProperties() { + INDI::DefaultDevice::initProperties(); + + // Connection mode + IUFillSwitch(&ConnModeS[0], "WIFI", "WiFi / Alpaca", ISS_ON); + IUFillSwitch(&ConnModeS[1], "USB", "USB Serial (Alnitak)", ISS_OFF); + IUFillSwitchVector(&ConnModeSP, ConnModeS, 2, getDeviceName(), + "CONN_MODE", "Connection Mode", CONNECTION_TAB, + IP_RW, ISR_1OFMANY, 60, IPS_IDLE); + + // WiFi host / port + IUFillText(&HostT[0], "HOST", "Host / IP", "esp32-flatpanel.local"); + IUFillText(&HostT[1], "PORT", "Port", "11111"); + IUFillTextVector(&HostTP, HostT, 2, getDeviceName(), + "ALPACA_HOST", "Alpaca Server", CONNECTION_TAB, + IP_RW, 60, IPS_IDLE); + + // USB serial port path + IUFillText(&SerialPortT[0], "PORT", "Serial Port", "/dev/ttyUSB0"); + IUFillTextVector(&SerialPortTP, SerialPortT, 1, getDeviceName(), + "SERIAL_PORT", "Serial Port", CONNECTION_TAB, + IP_RW, 60, IPS_IDLE); + + // Dew heater 0-100 % + IUFillNumber(&DewHeaterN[0], "DEW_HEATER_PCT", "Dew Heater (%)", "%.0f", 0, 100, 5, 0); + IUFillNumberVector(&DewHeaterNP, DewHeaterN, 1, getDeviceName(), + "DEW_HEATER", "Dew Heater", MAIN_CONTROL_TAB, + IP_RW, 60, IPS_IDLE); + + LightBoxInterface::initProperties(MAIN_CONTROL_TAB, MAIN_CONTROL_TAB); + DustCapInterface::initProperties(MAIN_CONTROL_TAB); + + addAuxControls(); + setDriverInterface(AUX_INTERFACE | LIGHTBOX_INTERFACE | DUSTCAP_INTERFACE); + return true; +} + +bool ESP32FlatPanel::updateProperties() { + INDI::DefaultDevice::updateProperties(); + + if (isConnected()) { + defineProperty(&ConnModeSP); + + if (m_connMode == CONN_WIFI) { + defineProperty(&HostTP); + } else { + defineProperty(&SerialPortTP); + } + + defineProperty(&DewHeaterNP); + LightBoxInterface::updateProperties(); + DustCapInterface::updateProperties(); + + // Sync initial state from device + pollState(); + SetTimer(1000); + } else { + deleteProperty(ConnModeSP.name); + deleteProperty(HostTP.name); + deleteProperty(SerialPortTP.name); + deleteProperty(DewHeaterNP.name); + LightBoxInterface::updateProperties(); + DustCapInterface::updateProperties(); + } + return true; +} + +bool ESP32FlatPanel::Connect() { + // Read connection mode + m_connMode = (ConnModeS[1].s == ISS_ON) ? CONN_USB : CONN_WIFI; + + if (m_connMode == CONN_WIFI) { + m_host = HostT[0].text; + m_port = std::atoi(HostT[1].text); + + int iface = alpacaGetInt("/api/v1/covercalibrator/0/interfaceversion", -1); + if (iface < 0) { + LOG_ERROR("Cannot reach ESP32 at the specified host/port."); + return false; + } + alpacaPutVoid("/api/v1/covercalibrator/0/connected", "Connected=true"); + LOGF_INFO("Connected via WiFi to %s:%d", m_host.c_str(), m_port); + } else { + if (!openSerial(SerialPortT[0].text)) { + LOGF_ERROR("Cannot open serial port %s", SerialPortT[0].text); + return false; + } + // Ping to verify + std::string resp = sendAlnitak(">P000"); + if (resp.empty() || resp[0] != '*') { + LOG_ERROR("No valid Alnitak ping response."); + closeSerial(); + return false; + } + LOGF_INFO("Connected via USB/Alnitak on %s", SerialPortT[0].text); + } + return true; +} + +bool ESP32FlatPanel::Disconnect() { + if (m_connMode == CONN_WIFI) + alpacaPutVoid("/api/v1/covercalibrator/0/connected", "Connected=false"); + else + closeSerial(); + + LOG_INFO("Disconnected from ESP32 FlatPanel."); + return true; +} + +void ESP32FlatPanel::TimerHit() { + if (!isConnected()) return; + pollState(); + SetTimer(1000); +} + +// --------------------------------------------------------------------------- +// Property change handlers +// --------------------------------------------------------------------------- +bool ESP32FlatPanel::ISNewSwitch(const char* dev, const char* name, ISState* states, char* names[], int n) { + if (!strcmp(dev, getDeviceName()) && !strcmp(name, ConnModeSP.name)) { + IUUpdateSwitch(&ConnModeSP, states, names, n); + ConnModeSP.s = IPS_OK; + IDSetSwitch(&ConnModeSP, nullptr); + return true; + } + if (LightBoxInterface::ISNewSwitch(dev, name, states, names, n)) return true; + if (DustCapInterface::ISNewSwitch(dev, name, states, names, n)) return true; + return INDI::DefaultDevice::ISNewSwitch(dev, name, states, names, n); +} + +bool ESP32FlatPanel::ISNewText(const char* dev, const char* name, char* texts[], char* names[], int n) { + if (!strcmp(dev, getDeviceName())) { + if (!strcmp(name, HostTP.name)) { + IUUpdateText(&HostTP, texts, names, n); + HostTP.s = IPS_OK; + IDSetText(&HostTP, nullptr); + return true; + } + if (!strcmp(name, SerialPortTP.name)) { + IUUpdateText(&SerialPortTP, texts, names, n); + SerialPortTP.s = IPS_OK; + IDSetText(&SerialPortTP, nullptr); + return true; + } + } + if (LightBoxInterface::ISNewText(dev, name, texts, names, n)) return true; + return INDI::DefaultDevice::ISNewText(dev, name, texts, names, n); +} + +bool ESP32FlatPanel::ISNewNumber(const char* dev, const char* name, double values[], char* names[], int n) { + if (!strcmp(dev, getDeviceName()) && !strcmp(name, DewHeaterNP.name)) { + IUUpdateNumber(&DewHeaterNP, values, names, n); + setDewHeater(static_cast(DewHeaterN[0].value)); + return true; + } + if (LightBoxInterface::ISNewNumber(dev, name, values, names, n)) return true; + return INDI::DefaultDevice::ISNewNumber(dev, name, values, names, n); +} + +bool ESP32FlatPanel::ISSnoopDevice(XMLEle* root) { + LightBoxInterface::snoop(root); + return INDI::DefaultDevice::ISSnoopDevice(root); +} + +// --------------------------------------------------------------------------- +// LightBoxInterface implementation +// --------------------------------------------------------------------------- +bool ESP32FlatPanel::EnableLightBox(bool enable) { + if (m_connMode == CONN_WIFI) { + if (enable) { + int b = static_cast(LightIntensityN[0].value); + if (b == 0) b = 100; + std::string body = commonPutBody() + "&Brightness=" + std::to_string(b); + return alpacaPutVoid("/api/v1/covercalibrator/0/calibratoron", body); + } + return alpacaPutVoid("/api/v1/covercalibrator/0/calibratoroff"); + } else { + if (enable) { + int b = static_cast(LightIntensityN[0].value); + if (b == 0) b = 100; + return alnitakSetBrightness(b); + } + return alnitakSetBrightness(0); + } +} + +bool ESP32FlatPanel::SetLightBoxBrightness(uint16_t value) { + if (m_connMode == CONN_WIFI) { + std::string body = commonPutBody() + "&Brightness=" + std::to_string(static_cast(value)); + return alpacaPutVoid("/api/v1/covercalibrator/0/calibratoron", body); + } + return alnitakSetBrightness(static_cast(value)); +} + +// --------------------------------------------------------------------------- +// DustCapInterface implementation +// ParkCap = close cover (panel over telescope aperture, ready for flats) +// UnParkCap = open cover (panel rotated clear, telescope free for imaging) +// --------------------------------------------------------------------------- +IPState ESP32FlatPanel::ParkCap() { + bool ok = (m_connMode == CONN_WIFI) + ? alpacaPutVoid("/api/v1/covercalibrator/0/closecover") + : !sendAlnitak(">C000").empty(); + if (!ok) return IPS_ALERT; + LOG_INFO("Closing cover…"); + return IPS_BUSY; +} + +IPState ESP32FlatPanel::UnParkCap() { + bool ok = (m_connMode == CONN_WIFI) + ? alpacaPutVoid("/api/v1/covercalibrator/0/opencover") + : !sendAlnitak(">O000").empty(); + if (!ok) return IPS_ALERT; + LOG_INFO("Opening cover…"); + return IPS_BUSY; +} + +// --------------------------------------------------------------------------- +// Dew heater control +// --------------------------------------------------------------------------- +void ESP32FlatPanel::setDewHeater(int pct) { + pct = std::max(0, std::min(100, pct)); + + bool ok; + if (m_connMode == CONN_WIFI) { + std::string body = commonPutBody() + + "&Action=SetDewHeater&Parameters=" + std::to_string(pct); + ok = alpacaPutVoid("/api/v1/covercalibrator/0/action", body); + } else { + ok = alnitakSetDewPercent(pct); + } + + DewHeaterN[0].value = pct; + DewHeaterNP.s = ok ? IPS_OK : IPS_ALERT; + IDSetNumber(&DewHeaterNP, nullptr); +} + +// --------------------------------------------------------------------------- +// State polling +// --------------------------------------------------------------------------- +void ESP32FlatPanel::pollState() { + int covState, calState, bright, dew; + + if (m_connMode == CONN_WIFI) { + covState = alpacaGetInt("/api/v1/covercalibrator/0/coverstate", COVER_CLOSED); + calState = alpacaGetInt("/api/v1/covercalibrator/0/calibratorstate", CAL_OFF); + bright = alpacaGetInt("/api/v1/covercalibrator/0/brightness", 0); + // Dew heater via custom Alpaca action + std::string resp = httpGet("/api/v1/covercalibrator/0/action"); + dew = 0; + try { + auto j = json::parse(httpGet("/api/v1/covercalibrator/0/brightness")); + // Poll dew via supportedactions action isn't a GET — just keep last known value + dew = static_cast(DewHeaterN[0].value); + } catch (...) {} + } else { + covState = alnitakGetCoverState(); + bright = alnitakGetBrightness(); + calState = (bright > 0) ? CAL_READY : CAL_OFF; + dew = alnitakGetDewPercent(); + } + + // Update cover + IPState newCovState = IPS_IDLE; + if (covState == COVER_MOVING) { + newCovState = IPS_BUSY; + } else if (covState == COVER_OPEN || covState == COVER_CLOSED) { + if (ParkCapSP.s == IPS_BUSY) { + LOG_INFO(covState == COVER_OPEN ? "Cover fully open." : "Cover fully closed."); + } + newCovState = IPS_OK; + ParkCapSP[CAP_PARK].s = (covState == COVER_CLOSED) ? ISS_ON : ISS_OFF; + ParkCapSP[CAP_UNPARK].s = (covState == COVER_OPEN) ? ISS_ON : ISS_OFF; + } else { + newCovState = IPS_ALERT; + } + if (newCovState != ParkCapSP.s) { + ParkCapSP.s = newCovState; + IDSetSwitch(&ParkCapSP, nullptr); + } + + // Update brightness + LightIntensityN[0].value = bright; + LightIntensityNP.s = (calState == CAL_READY) ? IPS_OK : IPS_IDLE; + IDSetNumber(&LightIntensityNP, nullptr); + + LightSP[FLAT_LIGHT_ON].s = (calState == CAL_READY) ? ISS_ON : ISS_OFF; + LightSP[FLAT_LIGHT_OFF].s = (calState != CAL_READY) ? ISS_ON : ISS_OFF; + LightSP.s = IPS_OK; + IDSetSwitch(&LightSP, nullptr); + + // Update dew heater + if (dew >= 0) { + DewHeaterN[0].value = dew; + DewHeaterNP.s = IPS_OK; + IDSetNumber(&DewHeaterNP, nullptr); + } +} + +// --------------------------------------------------------------------------- +// USB / Alnitak serial helpers +// --------------------------------------------------------------------------- +bool ESP32FlatPanel::openSerial(const std::string& device) { + m_serialFd = open(device.c_str(), O_RDWR | O_NOCTTY | O_NONBLOCK); + if (m_serialFd < 0) return false; + + struct termios tty{}; + tcgetattr(m_serialFd, &tty); + cfsetspeed(&tty, B9600); // standard Alnitak / N.I.N.A. baud rate + tty.c_cflag = CS8 | CREAD | CLOCAL; + tty.c_iflag = IGNBRK | IGNPAR; + tty.c_oflag = 0; + tty.c_lflag = 0; + tty.c_cc[VMIN] = 0; + tty.c_cc[VTIME] = 20; // 2 second read timeout (units of 0.1 s) + tcsetattr(m_serialFd, TCSANOW, &tty); + tcflush(m_serialFd, TCIOFLUSH); + return true; +} + +void ESP32FlatPanel::closeSerial() { + if (m_serialFd >= 0) { + close(m_serialFd); + m_serialFd = -1; + } +} + +std::string ESP32FlatPanel::sendAlnitak(const std::string& cmd) { + if (m_serialFd < 0) return {}; + + std::string full = cmd + "\r"; + if (write(m_serialFd, full.c_str(), full.size()) < 0) return {}; + + // Read response up to '\r', with 2-second timeout + char buf[32]; + int pos = 0; + char c; + unsigned long deadline_ms = 2000; + struct timeval tv; + fd_set readset; + + auto remaining = [&]() -> struct timeval { + return {static_cast(deadline_ms / 1000), + static_cast((deadline_ms % 1000) * 1000)}; + }; + + while (true) { + FD_ZERO(&readset); + FD_SET(m_serialFd, &readset); + tv = remaining(); + int ret = select(m_serialFd + 1, &readset, nullptr, nullptr, &tv); + if (ret <= 0) break; + if (read(m_serialFd, &c, 1) != 1) break; + if (c == '\r' || c == '\n') { + if (pos > 0) break; + } else if (pos < 30) { + buf[pos++] = c; + } + } + buf[pos] = '\0'; + return std::string(buf); +} + +int ESP32FlatPanel::alnitakGetBrightness() { + std::string r = sendAlnitak(">J000"); + if (r.size() < 5 || r[0] != '*' || r[1] != 'J') return 0; + // *JDD BBB (DD = device id, BBB = brightness) + int devId = 0, bright = 0; + sscanf(r.c_str() + 2, "%2d%3d", &devId, &bright); + return bright; +} + +bool ESP32FlatPanel::alnitakSetBrightness(int b) { + char cmd[8]; + snprintf(cmd, sizeof(cmd), ">B%03d", b); + std::string r = sendAlnitak(cmd); + return !r.empty() && r[0] == '*' && r[1] == 'B'; +} + +int ESP32FlatPanel::alnitakGetCoverState() { + // Returns Alpaca CoverStatus int + std::string r = sendAlnitak(">S000"); + if (r.size() < 7 || r[0] != '*' || r[1] != 'S') return COVER_CLOSED; + + int devId = 0, motorState = 0, lightState = 0, extra = 0; + sscanf(r.c_str() + 2, "%2d%1d%1d%2d", &devId, &motorState, &lightState, &extra); + + // Alnitak motor: 0=unknown 1=Closed 2=Open + switch (motorState) { + case 2: return COVER_OPEN; + case 1: return COVER_CLOSED; + default: return COVER_MOVING; + } +} + +int ESP32FlatPanel::alnitakGetDewPercent() { + // Custom command >U000 → *UDDppp + std::string r = sendAlnitak(">U000"); + if (r.size() < 5 || r[0] != '*' || r[1] != 'U') return -1; + int devId = 0, pct = 0; + sscanf(r.c_str() + 2, "%2d%3d", &devId, &pct); + return pct; +} + +bool ESP32FlatPanel::alnitakSetDewPercent(int pct) { + char cmd[8]; + snprintf(cmd, sizeof(cmd), ">T%03d", pct); + std::string r = sendAlnitak(cmd); + return !r.empty() && r[0] == '*' && r[1] == 'T'; +} + +// --------------------------------------------------------------------------- +// HTTP / Alpaca helpers (WiFi mode) +// --------------------------------------------------------------------------- +size_t ESP32FlatPanel::writeCallback(void* ptr, size_t size, size_t nmemb, std::string* out) { + out->append(static_cast(ptr), size * nmemb); + return size * nmemb; +} + +std::string ESP32FlatPanel::alpacaURL(const std::string& endpoint) const { + return "http://" + m_host + ":" + std::to_string(m_port) + endpoint; +} + +std::string ESP32FlatPanel::commonPutBody() { + return "ClientID=1&ClientTransactionID=" + std::to_string(++m_txID); +} + +std::string ESP32FlatPanel::httpGet(const std::string& endpoint) { + std::string url = alpacaURL(endpoint) + + "?ClientID=1&ClientTransactionID=" + std::to_string(++m_txID); + std::string response; + + CURL* curl = curl_easy_init(); + if (!curl) return {}; + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &ESP32FlatPanel::writeCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 3L); + curl_easy_perform(curl); + curl_easy_cleanup(curl); + return response; +} + +bool ESP32FlatPanel::httpPut(const std::string& endpoint, const std::string& body) { + std::string url = alpacaURL(endpoint); + std::string response; + + CURL* curl = curl_easy_init(); + if (!curl) return false; + + curl_slist* headers = curl_slist_append(nullptr, + "Content-Type: application/x-www-form-urlencoded"); + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT"); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &ESP32FlatPanel::writeCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 3L); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + if (res != CURLE_OK) return false; + + try { + return json::parse(response).value("ErrorNumber", -1) == 0; + } catch (...) { + return false; + } +} + +int ESP32FlatPanel::alpacaGetInt(const std::string& endpoint, int defaultVal) { + std::string resp = httpGet(endpoint); + if (resp.empty()) return defaultVal; + try { + auto j = json::parse(resp); + if (j.value("ErrorNumber", -1) != 0) return defaultVal; + return j.value("Value", defaultVal); + } catch (...) { + return defaultVal; + } +} + +bool ESP32FlatPanel::alpacaGetBool(const std::string& endpoint, bool defaultVal) { + std::string resp = httpGet(endpoint); + if (resp.empty()) return defaultVal; + try { + auto j = json::parse(resp); + if (j.value("ErrorNumber", -1) != 0) return defaultVal; + return j.value("Value", defaultVal); + } catch (...) { + return defaultVal; + } +} + +bool ESP32FlatPanel::alpacaPutVoid(const std::string& endpoint, const std::string& extraBody) { + std::string body = commonPutBody(); + if (!extraBody.empty()) body += "&" + extraBody; + return httpPut(endpoint, body); +} diff --git a/indi-driver/indi_esp32_flatpanel.h b/indi-driver/indi_esp32_flatpanel.h new file mode 100644 index 0000000..87a3c48 --- /dev/null +++ b/indi-driver/indi_esp32_flatpanel.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include +#include + +class ESP32FlatPanel : public INDI::DefaultDevice, + public INDI::LightBoxInterface, + public INDI::DustCapInterface +{ +public: + ESP32FlatPanel(); + ~ESP32FlatPanel() override; + + // DefaultDevice + const char* getDefaultName() override; + bool initProperties() override; + bool updateProperties() override; + bool Connect() override; + bool Disconnect() override; + void TimerHit() override; + + bool ISNewText (const char* dev, const char* name, char* texts[], char* names[], int n) override; + bool ISNewSwitch(const char* dev, const char* name, ISState* states, char* names[], int n) override; + bool ISNewNumber(const char* dev, const char* name, double values[], char* names[], int n) override; + bool ISSnoopDevice(XMLEle* root) override; + + // LightBoxInterface + bool SetLightBoxBrightness(uint16_t value) override; + bool EnableLightBox(bool enable) override; + + // DustCapInterface + IPState ParkCap() override; + IPState UnParkCap() override; + +private: + // ---- Connection mode switch ------------------------------------------ + enum ConnMode { CONN_WIFI, CONN_USB }; + ConnMode m_connMode { CONN_WIFI }; + + ISwitch ConnModeS[2]{}; + ISwitchVectorProperty ConnModeSP; + + // ---- WiFi / Alpaca settings ------------------------------------------ + IText HostT[2]{}; + ITextVectorProperty HostTP; + + std::string m_host {"esp32-flatpanel.local"}; + int m_port {11111}; + uint32_t m_clientID {1}; + uint32_t m_txID {0}; + + // ---- USB / Alnitak settings ------------------------------------------ + IText SerialPortT[1]{}; + ITextVectorProperty SerialPortTP; + + int m_serialFd {-1}; + + // ---- Dew heater property -------------------------------------------- + INumber DewHeaterN[1]{}; + INumberVectorProperty DewHeaterNP; + + // ---- CURL / HTTP helpers (WiFi mode) -------------------------------- + static size_t writeCallback(void* ptr, size_t size, size_t nmemb, std::string* out); + std::string httpGet(const std::string& endpoint); + bool httpPut(const std::string& endpoint, const std::string& body); + std::string alpacaURL(const std::string& endpoint) const; + std::string commonPutBody(); + int alpacaGetInt (const std::string& endpoint, int defaultVal = 0); + bool alpacaGetBool(const std::string& endpoint, bool defaultVal = false); + bool alpacaPutVoid(const std::string& endpoint, const std::string& extraBody = ""); + + // ---- Alnitak serial helpers (USB mode) ------------------------------ + bool openSerial(const std::string& device); + void closeSerial(); + std::string sendAlnitak(const std::string& cmd); + int alnitakGetBrightness(); + bool alnitakSetBrightness(int b); + int alnitakGetCoverState(); // returns Alpaca CoverStatus int + int alnitakGetDewPercent(); + bool alnitakSetDewPercent(int pct); + + // ---- Common helpers ------------------------------------------------- + void setDewHeater(int pct); + void pollState(); +}; diff --git a/indi-driver/indi_esp32_flatpanel.xml b/indi-driver/indi_esp32_flatpanel.xml new file mode 100644 index 0000000..68c0549 --- /dev/null +++ b/indi-driver/indi_esp32_flatpanel.xml @@ -0,0 +1,9 @@ + + + + + indi_esp32_flatpanel + 1.0 + + +