Initial release v1.1.0 — ESP32 automated telescope flat panel

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
This commit is contained in:
Laurence 2026-05-17 08:51:29 +01:00
commit c32f00a2be
15 changed files with 3653 additions and 0 deletions

101
CHANGELOG.md Normal file
View file

@ -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 0100% 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 (0100%), 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 (`0100%`, 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

275
README.md Normal file
View file

@ -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 (0255) via logic-level MOSFET |
| **Motorised cover** | MG995 servo rotates panel in/out of the telescope light path |
| **Dew heater** | 12V resistive element, 0100% 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://<host>: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 0255 |
| GET | `maxbrightness` | — | Always 255 |
| GET | `dewheater` | — | Current dew heater 0100% *(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 0100% *(custom)* |
| PUT | `action` | `Action=SetDewHeater`<br>`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 0255 | `*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.

177
WIRING.md Normal file
View file

@ -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://<IP>: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
```

View file

@ -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: *SDD<motor1><light1>00
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'<span style="color:{colour}; font-size:18px;">●</span>'
f'<span style="margin-left:6px; color:#e0e0e0;"> {text}</span>'
)
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(
'<span style="color:#9e9e9e; font-size:18px;">●</span>'
'<span style="color:#e0e0e0;"> OFF 0 %</span>'
)
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'<span style="color:{colour}; font-size:18px;">●</span>'
f'<span style="color:#e0e0e0;"> {"ON" if dew > 0 else "OFF"} {dew}%</span>'
)
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()

View file

@ -0,0 +1,3 @@
PyQt6>=6.4.0
requests>=2.28.0
pyserial>=3.5

289
docs/BUILD_NOTES.md Normal file
View file

@ -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 80150mm (**recommended starting point**) |
| 16 Ω | 9 W | Newtonians 150250mm |
| 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 3050%.
---
## 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.95.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://<IP>: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 |

282
docs/system-diagram.svg Normal file
View file

@ -0,0 +1,282 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="860" height="520" viewBox="0 0 860 520">
<defs>
<!-- Arrow markers -->
<marker id="arr-blue" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#1565c0"/>
</marker>
<marker id="arr-green" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#2e7d32"/>
</marker>
<marker id="arr-grey" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#607d8b"/>
</marker>
<marker id="arr-red" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#c62828"/>
</marker>
<marker id="arr-orange" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#e65100"/>
</marker>
</defs>
<!-- Background -->
<rect width="860" height="520" fill="#fafafa"/>
<!-- Title -->
<text x="430" y="32" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="17" font-weight="bold" fill="#212121">
ESP32 FlatPanel — System Architecture
</text>
<!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- PC Software block -->
<!-- ══════════════════════════════════════════════════════════════════════ -->
<rect x="20" y="55" width="230" height="230" rx="10"
fill="#e3f2fd" stroke="#1565c0" stroke-width="2"/>
<text x="135" y="79" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="13" font-weight="bold" fill="#0d47a1">
PC Software
</text>
<!-- N.I.N.A / ASCOM sub-box -->
<rect x="38" y="90" width="194" height="58" rx="6"
fill="#bbdefb" stroke="#1565c0" stroke-width="1"/>
<text x="135" y="114" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="11" font-weight="bold" fill="#0d47a1">
N.I.N.A. / SGPro / KStars
</text>
<text x="135" y="132" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#1565c0">
via ASCOM Remote / INDI driver
</text>
<!-- Controller sub-box (highlighted) -->
<rect x="38" y="162" width="194" height="58" rx="6"
fill="#1565c0" stroke="#0d47a1" stroke-width="1.5"/>
<text x="135" y="186" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="12" font-weight="bold" fill="white">
FlatPanel Controller
</text>
<text x="135" y="204" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#bbdefb">
flatpanel_controller.py
</text>
<!-- OS label -->
<text x="135" y="258" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#555">
Windows / Linux / macOS
</text>
<!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- ESP32 central block -->
<!-- ══════════════════════════════════════════════════════════════════════ -->
<rect x="300" y="130" width="210" height="230" rx="12"
fill="#f3e5f5" stroke="#6a1b9a" stroke-width="2.5"/>
<text x="405" y="157" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="13" font-weight="bold" fill="#4a148c">
Arduino Nano ESP32
</text>
<!-- Pin labels inside ESP32 box -->
<text x="405" y="181" text-anchor="middle"
font-family="Courier New, monospace" font-size="10" fill="#6a1b9a">
WiFi • USB CDC
</text>
<line x1="320" y1="192" x2="490" y2="192" stroke="#ce93d8" stroke-width="1"/>
<text x="405" y="211" text-anchor="middle"
font-family="Courier New, monospace" font-size="10" fill="#555">
Alpaca REST :11111
</text>
<text x="405" y="228" text-anchor="middle"
font-family="Courier New, monospace" font-size="10" fill="#555">
Alnitak serial 9600 baud
</text>
<line x1="320" y1="238" x2="490" y2="238" stroke="#ce93d8" stroke-width="1"/>
<text x="405" y="257" text-anchor="middle"
font-family="Courier New, monospace" font-size="10" fill="#555">
D9 → servo PWM
</text>
<text x="405" y="273" text-anchor="middle"
font-family="Courier New, monospace" font-size="10" fill="#555">
D3 → LED brightness PWM
</text>
<text x="405" y="289" text-anchor="middle"
font-family="Courier New, monospace" font-size="10" fill="#555">
D5 → dew heater PWM
</text>
<text x="405" y="305" text-anchor="middle"
font-family="Courier New, monospace" font-size="10" fill="#555">
VIN ← 5V (LM2596)
</text>
<text x="405" y="321" text-anchor="middle"
font-family="Courier New, monospace" font-size="10" fill="#555">
GND → common ground
</text>
<!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- Output blocks (right column) -->
<!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- MG995 Servo -->
<rect x="580" y="55" width="200" height="70" rx="8"
fill="#e8f5e9" stroke="#2e7d32" stroke-width="1.8"/>
<text x="680" y="82" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="12" font-weight="bold" fill="#1b5e20">
MG995 Servo
</text>
<text x="680" y="100" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#388e3c">
Cover open / close mechanism
</text>
<text x="680" y="115" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#388e3c">
5V supply via LM2596
</text>
<!-- LED MOSFET + Panel -->
<rect x="580" y="150" width="200" height="70" rx="8"
fill="#fffde7" stroke="#f9a825" stroke-width="1.8"/>
<text x="680" y="177" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="12" font-weight="bold" fill="#e65100">
IRLZ44N MOSFET-A
</text>
<text x="680" y="195" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#bf360c">
→ White LED Panel (12V)
</text>
<text x="680" y="211" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#bf360c">
PWM brightness 0255
</text>
<!-- Dew Heater MOSFET -->
<rect x="580" y="245" width="200" height="70" rx="8"
fill="#fce4ec" stroke="#c62828" stroke-width="1.8"/>
<text x="680" y="272" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="12" font-weight="bold" fill="#b71c1c">
IRLZ44N MOSFET-B
</text>
<text x="680" y="290" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#c62828">
→ Dew Heater Element (12V)
</text>
<text x="680" y="306" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#c62828">
PWM power 0100 %
</text>
<!-- PSU + Buck -->
<rect x="580" y="340" width="200" height="90" rx="8"
fill="#ede7f6" stroke="#6a1b9a" stroke-width="1.8"/>
<text x="680" y="367" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="12" font-weight="bold" fill="#4a148c">
12V PSU (≥4A)
</text>
<text x="680" y="386" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#6a1b9a">
→ 12V rail: LED + dew heater
</text>
<text x="680" y="402" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#6a1b9a">
→ LM2596 buck → 5V rail
</text>
<text x="680" y="418" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#6a1b9a">
→ 5V: ESP32 VIN + servo
</text>
<!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- Connection arrows: PC → ESP32 -->
<!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- WiFi (dashed blue) -->
<path d="M 250 119 C 280 119 275 185 300 185"
fill="none" stroke="#1565c0" stroke-width="2" stroke-dasharray="7,4"
marker-end="url(#arr-blue)"/>
<text x="263" y="153" font-family="Segoe UI, Arial, sans-serif"
font-size="10" fill="#1565c0" transform="rotate(-10, 263, 153)">
HTTP / Alpaca
</text>
<text x="263" y="165" font-family="Segoe UI, Arial, sans-serif"
font-size="9" fill="#1565c0" transform="rotate(-10, 263, 165)">
WiFi :11111
</text>
<!-- USB (solid green) -->
<path d="M 250 191 C 278 191 275 230 300 230"
fill="none" stroke="#2e7d32" stroke-width="2"
marker-end="url(#arr-green)"/>
<text x="258" y="223" font-family="Segoe UI, Arial, sans-serif"
font-size="10" fill="#2e7d32" transform="rotate(5, 258, 223)">
USB / Alnitak
</text>
<text x="258" y="235" font-family="Segoe UI, Arial, sans-serif"
font-size="9" fill="#2e7d32" transform="rotate(5, 258, 235)">
9600 baud serial
</text>
<!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- ESP32 → outputs -->
<!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- → MG995 (D9) -->
<path d="M 510 185 C 545 185 545 90 580 90"
fill="none" stroke="#2e7d32" stroke-width="1.8"
marker-end="url(#arr-green)"/>
<text x="534" y="130" font-family="Segoe UI, Arial, sans-serif"
font-size="9" fill="#2e7d32">D9</text>
<!-- → LED MOSFET (D3) -->
<line x1="510" y1="220" x2="580" y2="185"
stroke="#f9a825" stroke-width="1.8" marker-end="url(#arr-orange)"/>
<text x="532" y="210" font-family="Segoe UI, Arial, sans-serif"
font-size="9" fill="#e65100">D3 PWM</text>
<!-- → Dew MOSFET (D5) -->
<line x1="510" y1="270" x2="580" y2="280"
stroke="#c62828" stroke-width="1.8" marker-end="url(#arr-red)"/>
<text x="522" y="268" font-family="Segoe UI, Arial, sans-serif"
font-size="9" fill="#c62828">D5 PWM</text>
<!-- PSU → ESP32 VIN (5V) -->
<path d="M 580 385 C 520 385 495 340 510 335"
fill="none" stroke="#e65100" stroke-width="1.8" stroke-dasharray="5,3"
marker-end="url(#arr-orange)"/>
<text x="490" y="373" font-family="Segoe UI, Arial, sans-serif"
font-size="9" fill="#e65100">5V VIN</text>
<!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- Legend -->
<!-- ══════════════════════════════════════════════════════════════════════ -->
<rect x="22" y="310" width="230" height="130" rx="6"
fill="#f5f5f5" stroke="#ccc" stroke-width="1"/>
<text x="35" y="330" font-family="Segoe UI, Arial, sans-serif"
font-size="11" font-weight="bold" fill="#333">Legend</text>
<line x1="35" y1="348" x2="80" y2="348" stroke="#1565c0" stroke-width="2" stroke-dasharray="7,4"/>
<text x="88" y="352" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">WiFi / Alpaca (HTTP)</text>
<line x1="35" y1="368" x2="80" y2="368" stroke="#2e7d32" stroke-width="2"/>
<text x="88" y="372" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">USB / Alnitak (serial)</text>
<line x1="35" y1="388" x2="80" y2="388" stroke="#f9a825" stroke-width="2"/>
<text x="88" y="392" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">GPIO / PWM signal</text>
<line x1="35" y1="408" x2="80" y2="408" stroke="#e65100" stroke-width="2" stroke-dasharray="5,3"/>
<text x="88" y="412" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">Power supply (5V)</text>
<text x="35" y="432" font-family="Segoe UI, Arial, sans-serif" font-size="9" fill="#777">
Both WiFi and USB active simultaneously
</text>
<!-- Version / note -->
<text x="430" y="505" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="9" fill="#999">
ESP32 FlatPanel v1.1 — github.com / your-repo
</text>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

313
docs/wiring-diagram.svg Normal file
View file

@ -0,0 +1,313 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="640" viewBox="0 0 960 640">
<defs>
<!-- Small filled resistor symbol via marker (not used rendered inline) -->
<marker id="a-red" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="#c62828"/>
</marker>
<marker id="a-orange" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="#e65100"/>
</marker>
<marker id="a-black" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="#212121"/>
</marker>
<marker id="a-blue" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="#1565c0"/>
</marker>
<marker id="a-yellow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="#f9a825"/>
</marker>
<marker id="a-grey" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="#616161"/>
</marker>
<marker id="a-purple" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="#6a1b9a"/>
</marker>
</defs>
<!-- White background -->
<rect width="960" height="640" fill="white"/>
<!-- Title -->
<text x="480" y="30" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="17" font-weight="bold" fill="#212121">
ESP32 FlatPanel — Wiring Diagram
</text>
<text x="480" y="48" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#777">
Wire colours: red=12V orange=5V black=GND blue=PWM signal yellow=servo signal grey=load output
</text>
<!-- ═══════════════════════════════════════════════════════════ COMPONENTS -->
<!-- 12V PSU -->
<rect x="30" y="70" width="130" height="80" rx="8" fill="#fff8e1" stroke="#f57f17" stroke-width="2"/>
<text x="95" y="100" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="12" font-weight="bold" fill="#e65100">12V PSU</text>
<text x="95" y="118" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="10" fill="#777">≥ 4A</text>
<!-- Terminals -->
<circle cx="50" cy="140" r="5" fill="#c62828"/><text x="50" y="158" text-anchor="middle" font-size="9" fill="#333">12V+</text>
<circle cx="95" cy="140" r="5" fill="#212121"/><text x="95" y="158" text-anchor="middle" font-size="9" fill="#333">GND</text>
<circle cx="140" cy="140" r="5" fill="#c62828"/><text x="140" y="158" text-anchor="middle" font-size="9" fill="#333">12V+</text>
<!-- LM2596 Buck Converter -->
<rect x="210" y="70" width="140" height="80" rx="8" fill="#f9fbe7" stroke="#827717" stroke-width="2"/>
<text x="280" y="97" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="12" font-weight="bold" fill="#558b2f">LM2596 Buck</text>
<text x="280" y="113" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="10" fill="#777">Set to 5.0V first!</text>
<circle cx="225" cy="140" r="5" fill="#c62828"/><text x="225" y="158" text-anchor="middle" font-size="9" fill="#333">IN+</text>
<circle cx="255" cy="140" r="5" fill="#212121"/><text x="255" y="158" text-anchor="middle" font-size="9" fill="#333">IN</text>
<circle cx="305" cy="140" r="5" fill="#e65100"/><text x="305" y="158" text-anchor="middle" font-size="9" fill="#333">OUT+</text>
<circle cx="335" cy="140" r="5" fill="#212121"/><text x="335" y="158" text-anchor="middle" font-size="9" fill="#333">OUT</text>
<!-- Arduino Nano ESP32 -->
<rect x="370" y="200" width="180" height="260" rx="10" fill="#e3f2fd" stroke="#1565c0" stroke-width="2.5"/>
<text x="460" y="226" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="12" font-weight="bold" fill="#0d47a1">Arduino Nano ESP32</text>
<!-- Pin labels -->
<text x="460" y="252" text-anchor="middle" font-family="Courier New, monospace" font-size="9" fill="#555">D9 → servo</text>
<text x="460" y="268" text-anchor="middle" font-family="Courier New, monospace" font-size="9" fill="#555">D3 → LED MOSFET gate</text>
<text x="460" y="284" text-anchor="middle" font-family="Courier New, monospace" font-size="9" fill="#555">D5 → Dew MOSFET gate</text>
<line x1="385" y1="295" x2="535" y2="295" stroke="#90caf9" stroke-width="1"/>
<text x="460" y="315" text-anchor="middle" font-family="Courier New, monospace" font-size="9" fill="#555">VIN ← 5V</text>
<text x="460" y="331" text-anchor="middle" font-family="Courier New, monospace" font-size="9" fill="#555">GND ← common GND</text>
<line x1="385" y1="342" x2="535" y2="342" stroke="#90caf9" stroke-width="1"/>
<text x="460" y="362" text-anchor="middle" font-family="Courier New, monospace" font-size="9" fill="#555">USB-C → PC</text>
<text x="460" y="378" text-anchor="middle" font-family="Courier New, monospace" font-size="9" fill="#555">(Alnitak 9600 baud)</text>
<text x="460" y="400" text-anchor="middle" font-family="Courier New, monospace" font-size="9" fill="#555">WiFi → Alpaca :11111</text>
<!-- ESP32 terminals (left side going out to servo) -->
<circle cx="370" cy="252" r="5" fill="#f9a825"/> <!-- D9 servo -->
<circle cx="370" cy="268" r="5" fill="#1565c0"/> <!-- D3 LED PWM -->
<circle cx="370" cy="284" r="5" fill="#1565c0"/> <!-- D5 Dew PWM -->
<circle cx="370" cy="315" r="5" fill="#e65100"/> <!-- VIN 5V -->
<circle cx="370" cy="331" r="5" fill="#212121"/> <!-- GND -->
<!-- USB port symbol on right -->
<rect x="542" y="358" width="8" height="16" rx="2" fill="#666"/>
<text x="556" y="370" font-family="Segoe UI, Arial, sans-serif" font-size="9" fill="#555">USB-C</text>
<!-- MG995 Servo -->
<rect x="30" y="290" width="140" height="80" rx="8" fill="#e8f5e9" stroke="#2e7d32" stroke-width="2"/>
<text x="100" y="320" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="12" font-weight="bold" fill="#1b5e20">MG995 Servo</text>
<text x="100" y="338" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="10" fill="#388e3c">Cover mechanism</text>
<circle cx="50" cy="360" r="5" fill="#212121"/><text x="50" y="378" text-anchor="middle" font-size="9" fill="#333">GND</text>
<circle cx="100" cy="360" r="5" fill="#e65100"/><text x="100" y="378" text-anchor="middle" font-size="9" fill="#333">5V</text>
<circle cx="150" cy="360" r="5" fill="#f9a825"/><text x="150" y="378" text-anchor="middle" font-size="9" fill="#333">Signal</text>
<!-- MOSFET-A (LED) -->
<rect x="210" y="290" width="140" height="100" rx="8" fill="#fff9c4" stroke="#f57f17" stroke-width="2"/>
<text x="280" y="317" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="11" font-weight="bold" fill="#e65100">IRLZ44N</text>
<text x="280" y="333" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="10" fill="#e65100">MOSFET-A (LED)</text>
<text x="280" y="349" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="9" fill="#777">Gate Drain Source</text>
<circle cx="235" cy="370" r="5" fill="#1565c0"/><text x="235" y="388" text-anchor="middle" font-size="9" fill="#333">Gate</text>
<circle cx="280" cy="370" r="5" fill="#616161"/><text x="280" y="388" text-anchor="middle" font-size="9" fill="#333">Drain</text>
<circle cx="325" cy="370" r="5" fill="#212121"/><text x="325" y="388" text-anchor="middle" font-size="9" fill="#333">Source</text>
<!-- MOSFET-B (Dew) -->
<rect x="210" y="430" width="140" height="100" rx="8" fill="#fce4ec" stroke="#c62828" stroke-width="2"/>
<text x="280" y="457" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="11" font-weight="bold" fill="#b71c1c">IRLZ44N</text>
<text x="280" y="473" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="10" fill="#b71c1c">MOSFET-B (Dew)</text>
<text x="280" y="489" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="9" fill="#777">Gate Drain Source</text>
<circle cx="235" cy="510" r="5" fill="#1565c0"/><text x="235" y="528" text-anchor="middle" font-size="9" fill="#333">Gate</text>
<circle cx="280" cy="510" r="5" fill="#616161"/><text x="280" y="528" text-anchor="middle" font-size="9" fill="#333">Drain</text>
<circle cx="325" cy="510" r="5" fill="#212121"/><text x="325" y="528" text-anchor="middle" font-size="9" fill="#333">Source</text>
<!-- LED Panel -->
<rect x="680" y="270" width="180" height="80" rx="8" fill="#fffde7" stroke="#f9a825" stroke-width="2"/>
<text x="770" y="300" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="12" font-weight="bold" fill="#e65100">LED Panel (12V)</text>
<text x="770" y="318" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="10" fill="#777">White high-CRI strip</text>
<text x="770" y="334" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="10" fill="#777">+ frosted acrylic</text>
<circle cx="710" cy="342" r="5" fill="#c62828"/><text x="710" y="360" text-anchor="middle" font-size="9" fill="#333">LED+</text>
<circle cx="760" cy="342" r="5" fill="#616161"/><text x="760" y="360" text-anchor="middle" font-size="9" fill="#333">LED</text>
<!-- Dew Heater Element -->
<rect x="680" y="420" width="180" height="80" rx="8" fill="#fbe9e7" stroke="#c62828" stroke-width="2"/>
<text x="770" y="450" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="12" font-weight="bold" fill="#b71c1c">Dew Heater</text>
<text x="770" y="468" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="10" fill="#777">Nichrome / heating tape</text>
<text x="770" y="484" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="10" fill="#777">~24Ω 6W @ 12V</text>
<circle cx="710" cy="492" r="5" fill="#c62828"/><text x="710" y="510" text-anchor="middle" font-size="9" fill="#333">H+</text>
<circle cx="760" cy="492" r="5" fill="#616161"/><text x="760" y="510" text-anchor="middle" font-size="9" fill="#333">H</text>
<!-- ═══════════════════════════════════════════════════════════════ WIRES -->
<!-- 12V rail: PSU 12V+ → LM2596 IN+ -->
<line x1="140" y1="140" x2="225" y2="140" stroke="#c62828" stroke-width="2.5" marker-end="url(#a-red)"/>
<!-- GND rail: PSU GND → LM2596 IN -->
<line x1="95" y1="140" x2="100" y2="175" stroke="#212121" stroke-width="2.5"/>
<line x1="100" y1="175" x2="255" y2="175" stroke="#212121" stroke-width="2.5" marker-end="url(#a-black)"/>
<text x="168" y="170" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif" font-size="9" fill="#333">GND bus</text>
<!-- 5V rail: LM2596 OUT+ → ESP32 VIN -->
<line x1="305" y1="140" x2="340" y2="140" stroke="#e65100" stroke-width="2.5"/>
<line x1="340" y1="140" x2="340" y2="315" stroke="#e65100" stroke-width="2.5"/>
<line x1="340" y1="315" x2="370" y2="315" stroke="#e65100" stroke-width="2.5" marker-end="url(#a-orange)"/>
<text x="322" y="230" font-family="Segoe UI, Arial, sans-serif" font-size="9" fill="#e65100"
transform="rotate(-90, 322, 230)">5V</text>
<!-- 5V rail: LM2596 OUT+ → MG995 VCC -->
<line x1="340" y1="330" x2="200" y2="330" stroke="#e65100" stroke-width="2.5"/>
<line x1="200" y1="330" x2="200" y2="360" stroke="#e65100" stroke-width="2.5"/>
<line x1="200" y1="360" x2="150" y2="360" stroke="#e65100" stroke-width="2.5" marker-end="url(#a-orange)"/>
<!-- GND rail: LM2596 OUT → ESP32 GND -->
<line x1="335" y1="175" x2="355" y2="175" stroke="#212121" stroke-width="2.5"/>
<line x1="355" y1="175" x2="355" y2="331" stroke="#212121" stroke-width="2.5"/>
<line x1="355" y1="331" x2="370" y2="331" stroke="#212121" stroke-width="2.5" marker-end="url(#a-black)"/>
<!-- GND rail: → MG995 GND -->
<line x1="355" y1="360" x2="100" y2="360" stroke="#212121" stroke-width="2.5" marker-end="url(#a-black)"/>
<!-- GND rail: → MOSFET-A Source -->
<line x1="325" y1="370" x2="325" y2="390" stroke="#212121" stroke-width="2.5"/>
<line x1="325" y1="390" x2="355" y2="390" stroke="#212121" stroke-width="2.5"/>
<line x1="355" y1="390" x2="355" y2="510" stroke="#212121" stroke-width="2.5"/>
<!-- GND rail: → MOSFET-B Source -->
<line x1="355" y1="510" x2="325" y2="510" stroke="#212121" stroke-width="2.5" marker-end="url(#a-black)"/>
<!-- Servo signal: ESP32 D9 → MG995 Signal -->
<line x1="370" y1="252" x2="200" y2="252" stroke="#f9a825" stroke-width="2" stroke-dasharray="6,3"/>
<line x1="200" y1="252" x2="200" y2="290" stroke="#f9a825" stroke-width="2" stroke-dasharray="6,3"/>
<line x1="200" y1="290" x2="170" y2="290" stroke="#f9a825" stroke-width="2" stroke-dasharray="6,3"/>
<line x1="170" y1="290" x2="170" y2="360" stroke="#f9a825" stroke-width="2" stroke-dasharray="6,3" marker-end="url(#a-yellow)"/>
<text x="220" y="248" font-family="Segoe UI, Arial, sans-serif" font-size="9" fill="#f9a825">D9 servo</text>
<!-- LED PWM: ESP32 D3 → 100Ω → MOSFET-A Gate -->
<!-- Wire from ESP32 D3 terminal -->
<line x1="370" y1="268" x2="248" y2="268" stroke="#1565c0" stroke-width="2"/>
<line x1="248" y1="268" x2="248" y2="290" stroke="#1565c0" stroke-width="2"/>
<!-- 100Ω resistor symbol (small rect) -->
<rect x="237" y="290" width="22" height="10" rx="2" fill="#fff" stroke="#1565c0" stroke-width="1.5"/>
<text x="248" y="299" text-anchor="middle" font-family="Courier New, monospace" font-size="7" fill="#1565c0">100R</text>
<!-- Continue to gate -->
<line x1="248" y1="300" x2="248" y2="370" stroke="#1565c0" stroke-width="2"/>
<line x1="248" y1="370" x2="235" y2="370" stroke="#1565c0" stroke-width="2" marker-end="url(#a-blue)"/>
<!-- 10kΩ pull-down from gate node to GND -->
<line x1="248" y1="340" x2="230" y2="340" stroke="#1565c0" stroke-width="1" stroke-dasharray="3,2"/>
<rect x="218" y="332" width="12" height="16" rx="2" fill="#fff" stroke="#999" stroke-width="1"/>
<text x="224" y="343" text-anchor="middle" font-family="Courier New, monospace" font-size="6" fill="#777">10k</text>
<text x="208" y="298" font-family="Segoe UI, Arial, sans-serif" font-size="9" fill="#1565c0">D3</text>
<!-- Dew PWM: ESP32 D5 → 100Ω → MOSFET-B Gate -->
<line x1="370" y1="284" x2="185" y2="284" stroke="#1565c0" stroke-width="2"/>
<line x1="185" y1="284" x2="185" y2="510" stroke="#1565c0" stroke-width="2"/>
<!-- 100Ω resistor -->
<rect x="174" y="490" width="22" height="10" rx="2" fill="#fff" stroke="#1565c0" stroke-width="1.5"/>
<text x="185" y="499" text-anchor="middle" font-family="Courier New, monospace" font-size="7" fill="#1565c0">100R</text>
<line x1="185" y1="500" x2="185" y2="510" stroke="#1565c0" stroke-width="2"/>
<line x1="185" y1="510" x2="235" y2="510" stroke="#1565c0" stroke-width="2" marker-end="url(#a-blue)"/>
<!-- 10kΩ pull-down -->
<line x1="185" y1="470" x2="165" y2="470" stroke="#1565c0" stroke-width="1" stroke-dasharray="3,2"/>
<rect x="153" y="462" width="12" height="16" rx="2" fill="#fff" stroke="#999" stroke-width="1"/>
<text x="159" y="473" text-anchor="middle" font-family="Courier New, monospace" font-size="6" fill="#777">10k</text>
<text x="196" y="281" font-family="Segoe UI, Arial, sans-serif" font-size="9" fill="#1565c0">D5</text>
<!-- 12V rail: PSU → 12V bus (vertical red rail on right) -->
<line x1="50" y1="140" x2="50" y2="165" stroke="#c62828" stroke-width="2.5"/>
<line x1="50" y1="165" x2="660" y2="165" stroke="#c62828" stroke-width="3"/>
<text x="550" y="160" font-family="Segoe UI, Arial, sans-serif" font-size="10"
font-weight="bold" fill="#c62828">12V rail</text>
<!-- 12V → LED Panel LED+ -->
<line x1="660" y1="165" x2="660" y2="310" stroke="#c62828" stroke-width="2.5"/>
<line x1="660" y1="310" x2="710" y2="310" stroke="#c62828" stroke-width="2.5" marker-end="url(#a-red)"/>
<!-- 12V → Dew Heater H+ -->
<line x1="640" y1="165" x2="640" y2="460" stroke="#c62828" stroke-width="2.5"/>
<line x1="640" y1="460" x2="710" y2="460" stroke="#c62828" stroke-width="2.5" marker-end="url(#a-red)"/>
<!-- MOSFET-A Drain → LED (load output wire) -->
<line x1="280" y1="370" x2="280" y2="410" stroke="#616161" stroke-width="2"/>
<line x1="280" y1="410" x2="620" y2="410" stroke="#616161" stroke-width="2"/>
<line x1="620" y1="410" x2="620" y2="342" stroke="#616161" stroke-width="2"/>
<line x1="620" y1="342" x2="760" y2="342" stroke="#616161" stroke-width="2" marker-end="url(#a-grey)"/>
<text x="430" y="406" font-family="Segoe UI, Arial, sans-serif" font-size="9" fill="#616161">LED (via MOSFET-A drain)</text>
<!-- MOSFET-B Drain → Dew Heater H -->
<line x1="280" y1="510" x2="280" y2="555" stroke="#616161" stroke-width="2"/>
<line x1="280" y1="555" x2="635" y2="555" stroke="#616161" stroke-width="2"/>
<line x1="635" y1="555" x2="635" y2="492" stroke="#616161" stroke-width="2"/>
<line x1="635" y1="492" x2="760" y2="492" stroke="#616161" stroke-width="2" marker-end="url(#a-grey)"/>
<text x="430" y="551" font-family="Segoe UI, Arial, sans-serif" font-size="9" fill="#616161">H (via MOSFET-B drain)</text>
<!-- ═══════════════════════════════════════════════════════════════ LEGEND -->
<rect x="560" y="60" width="200" height="190" rx="8" fill="#f5f5f5" stroke="#bdbdbd" stroke-width="1.5"/>
<text x="660" y="82" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
font-size="12" font-weight="bold" fill="#333">Wire Legend</text>
<!-- Red -->
<line x1="578" y1="100" x2="618" y2="100" stroke="#c62828" stroke-width="3"/>
<text x="626" y="104" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">12V positive</text>
<!-- Orange -->
<line x1="578" y1="120" x2="618" y2="120" stroke="#e65100" stroke-width="3"/>
<text x="626" y="124" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">5V positive</text>
<!-- Black -->
<line x1="578" y1="140" x2="618" y2="140" stroke="#212121" stroke-width="3"/>
<text x="626" y="144" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">GND / ground</text>
<!-- Blue solid -->
<line x1="578" y1="160" x2="618" y2="160" stroke="#1565c0" stroke-width="2"/>
<text x="626" y="164" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">PWM signal</text>
<!-- Yellow dashed -->
<line x1="578" y1="180" x2="618" y2="180" stroke="#f9a825" stroke-width="2" stroke-dasharray="6,3"/>
<text x="626" y="184" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">Servo signal</text>
<!-- Grey -->
<line x1="578" y1="200" x2="618" y2="200" stroke="#616161" stroke-width="2"/>
<text x="626" y="204" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">Load output</text>
<!-- Resistor symbol -->
<rect x="578" y="214" width="20" height="10" rx="2" fill="#fff" stroke="#555" stroke-width="1.5"/>
<text x="626" y="224" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">Resistor</text>
<!-- ═══════════════════════════════════════════════════════════ NOTES BOX -->
<rect x="560" y="265" width="380" height="140" rx="8" fill="#e3f2fd" stroke="#1565c0" stroke-width="1.5"/>
<text x="570" y="287" font-family="Segoe UI, Arial, sans-serif" font-size="11" font-weight="bold" fill="#0d47a1">
⚠ Build Order
</text>
<text x="570" y="305" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">
1. Set LM2596 to 5.0V BEFORE connecting servo or ESP32
</text>
<text x="570" y="321" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">
2. Add 10kΩ Gate→GND pull-downs on both MOSFETs
</text>
<text x="570" y="337" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">
3. Add 100Ω Gate series resistors on both MOSFETs
</text>
<text x="570" y="353" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">
4. Test LED MOSFET with 5V bench supply before 12V
</text>
<text x="570" y="369" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#333">
5. Flash firmware &amp; verify via Serial before full assembly
</text>
<text x="570" y="393" font-family="Segoe UI, Arial, sans-serif" font-size="10" fill="#777">
MOSFET IRLZ44N pinout (flat face towards you): Gate | Drain | Source
</text>
<!-- Footer -->
<text x="480" y="625" text-anchor="middle"
font-family="Segoe UI, Arial, sans-serif" font-size="9" fill="#999">
Not a certified schematic — verify all connections before powering on. v1.1
</text>
</svg>

After

Width:  |  Height:  |  Size: 21 KiB

14
firmware/platformio.ini Normal file
View file

@ -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

58
firmware/src/config.h Normal file
View file

@ -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"

515
firmware/src/main.cpp Normal file
View file

@ -0,0 +1,515 @@
#include <Arduino.h>
#include <WiFi.h>
#include <ESPmDNS.h>
#include <AsyncUDP.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <ESP32Servo.h>
#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: *SDD<motor><light>00
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<char>(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<int> 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<int>(g_calState));
});
server.on("/api/v1/covercalibrator/0/coverstate", HTTP_GET, [](AsyncWebServerRequest* req) {
sendInt(req, static_cast<int>(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();
}
}
}

View file

@ -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})

View file

@ -0,0 +1,581 @@
#include "indi_esp32_flatpanel.h"
#include <libindi/indicom.h>
#include <curl/curl.h>
#include <nlohmann/json.hpp>
// POSIX serial port (USB/Alnitak mode)
#include <fcntl.h>
#include <termios.h>
#include <unistd.h>
#include <sys/select.h>
#include <cstring>
#include <sstream>
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<ESP32FlatPanel> 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<ESP32FlatPanel>();
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<int>(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<int>(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<int>(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<int>(value));
return alpacaPutVoid("/api/v1/covercalibrator/0/calibratoron", body);
}
return alnitakSetBrightness(static_cast<int>(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<int>(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<time_t>(deadline_ms / 1000),
static_cast<suseconds_t>((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<char*>(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);
}

View file

@ -0,0 +1,88 @@
#pragma once
#include <libindi/defaultdevice.h>
#include <libindi/indilightboxinterface.h>
#include <libindi/indidustcapinterface.h>
#include <curl/curl.h>
#include <string>
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();
};

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<driversList>
<devGroup group="Aux">
<device label="ESP32 FlatPanel" manufacturer="DIY">
<driver name="ESP32 FlatPanel">indi_esp32_flatpanel</driver>
<version>1.0</version>
</device>
</devGroup>
</driversList>