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:
commit
c32f00a2be
15 changed files with 3653 additions and 0 deletions
101
CHANGELOG.md
Normal file
101
CHANGELOG.md
Normal 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 0–100% to 8-bit LEDC PWM
|
||||
- Non-standard Alpaca GET/PUT endpoint `dewheater` for direct polling and control by the Python app and INDI driver
|
||||
- Dew heater exposed as `SupportedActions` entries `SetDewHeater` / `GetDewHeater` for full Alpaca Action compatibility
|
||||
- Custom Alnitak extension commands: `>T` (set percent) / `>U` (get percent)
|
||||
- Dew heater number property in the INDI driver (`MAIN_CONTROL_TAB`)
|
||||
- Dew heater slider + On/Off buttons in the Python controller
|
||||
|
||||
#### USB / Alnitak serial mode
|
||||
- Full implementation of the **Alnitak Flip-Flat serial protocol** on the ESP32 USB CDC port (9600 baud)
|
||||
- Supports all standard Alnitak commands: `P` ping, `S` state, `B`/`J` brightness set/get, `L`/`D` light on/off, `O`/`C`/`H` cover open/close/halt
|
||||
- Compatible with N.I.N.A., Sequence Generator Pro, Astro Photography Tool, and INDI `indi_flipflat` out of the box — no additional driver required
|
||||
- `SERIAL_DEBUG` compile flag in `config.h`: set `false` to silence all debug output and use the serial line exclusively for the Alnitak protocol
|
||||
- Serial baud rate set to **9600** to match all consumer Alnitak software defaults
|
||||
|
||||
#### Python desktop controller (`controller/flatpanel_controller.py`)
|
||||
- Dark-themed PyQt6 application suitable for night-time observatory use
|
||||
- Supports both WiFi/Alpaca and USB/Alnitak connection modes selectable at runtime
|
||||
- `AlpacaBackend` — HTTP via `requests.Session`, full Alpaca property access including custom `dewheater` endpoint
|
||||
- `AlnitakBackend` — `pyserial` serial communication with timeout handling and response validation
|
||||
- `PollWorker` — `QThread` subclass polling device state every 1 second without blocking the GUI
|
||||
- Cover panel: status dot (colour-coded by state), Open / Close / Halt buttons
|
||||
- Flat panel: brightness slider linked bidirectionally to a spinbox, Light On/Off
|
||||
- Dew heater panel: power slider (0–100%), Heater On/Off, live % status indicator
|
||||
- Scrolling, timestamped log panel (500-line cap)
|
||||
- `QSettings` persistence for connection mode, host, port, serial port, last brightness, last dew %
|
||||
- Auto-discovery of available serial ports with refresh button
|
||||
- `requirements.txt` added: `PyQt6`, `requests`, `pyserial`
|
||||
|
||||
#### INDI driver enhancements
|
||||
- **USB serial mode** added alongside existing WiFi/Alpaca mode; switchable via a `Connection Mode` switch property in the driver UI
|
||||
- Serial port text property (`/dev/ttyUSB0` default) shown when USB mode is selected
|
||||
- POSIX serial port implementation using `termios` / `select` with 2-second read timeout
|
||||
- Alnitak helper methods: `openSerial`, `closeSerial`, `sendAlnitak`, `alnitakGetBrightness`, `alnitakSetBrightness`, `alnitakGetCoverState`, `alnitakGetDewPercent`, `alnitakSetDewPercent`
|
||||
- `DewHeaterNP` number property (`0–100%`, step 5) exposed in `MAIN_CONTROL_TAB`
|
||||
- Dew heater control routes through Alpaca `dewheater` PUT (WiFi) or custom Alnitak `>T` (USB)
|
||||
|
||||
#### Documentation
|
||||
- `docs/system-diagram.svg` — architecture block diagram (800×520, browser-renderable SVG)
|
||||
- `docs/wiring-diagram.svg` — colour-coded component wiring diagram (960×640, browser-renderable SVG), includes build-order warning panel and legend
|
||||
- `docs/BUILD_NOTES.md` — full step-by-step assembly guide with BOM, dew heater sizing table, LM2596 calibration procedure, MOSFET wiring, 7-step first power-on checklist, software setup for all platforms, and troubleshooting table
|
||||
|
||||
### Changed
|
||||
- `Serial.begin()` baud rate changed from `115200` to **9600** to match Alnitak software defaults (USB CDC baud rate is virtual and has no real-world effect, but driver compatibility requires this)
|
||||
- `supportedactions` endpoint now returns `["SetDewHeater", "GetDewHeater"]` instead of an empty array
|
||||
- `description` endpoint updated to mention dew heater
|
||||
- `driverversion` bumped to `1.1.0`
|
||||
- WIRING.md updated: second MOSFET added to wiring diagram, dew heater element sizing table, USB/WiFi mode comparison table, pin summary table, PSU rating increased from ≥3A to **≥4A**
|
||||
|
||||
### Fixed
|
||||
- Cover `haltcover` Alpaca route now correctly reads current servo position via `coverServo.read()` before holding, preventing a potential servo jitter on halt
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] — 2026-05-17
|
||||
|
||||
Initial release.
|
||||
|
||||
### Added
|
||||
|
||||
#### Firmware
|
||||
- ASCOM Alpaca `CoverCalibrator` device type implemented natively on the ESP32 (no PC-side COM driver needed)
|
||||
- All standard `CoverCalibrator` properties and methods:
|
||||
- GET: `coverstate`, `calibratorstate`, `brightness`, `maxbrightness`, `connected`, `name`, `description`, `driverinfo`, `driverversion`, `interfaceversion`, `supportedactions`
|
||||
- PUT: `connected`, `calibratoron` (with `Brightness` parameter), `calibratoroff`, `opencover`, `closecover`, `haltcover`
|
||||
- MG995 servo cover mechanism on D9; non-blocking state machine with configurable movement timeout (`COVER_MOVE_TIMEOUT_MS`)
|
||||
- 8-bit LED brightness PWM on D3 via LEDC channel 0 (5kHz, above visible flicker)
|
||||
- WiFi connection with 30-second boot timeout and 10-second watchdog reconnect loop
|
||||
- mDNS advertising as `esp32-flatpanel.local` with `_alpaca._tcp` service record
|
||||
- Alpaca UDP discovery on port 32227 — responds to `alpacadiscovery1` broadcasts with `{"AlpacaPort":11111}`
|
||||
- `config.h` with all user-configurable settings in one place
|
||||
|
||||
#### INDI driver (WiFi / Alpaca mode)
|
||||
- `INDI::DefaultDevice` + `INDI::LightBoxInterface` + `INDI::DustCapInterface`
|
||||
- `AlpacaBackend` using `libcurl` for HTTP, `nlohmann/json` for response parsing
|
||||
- 1-second state polling via `TimerHit()`
|
||||
- `LightBoxInterface`: `SetLightBoxBrightness()`, `EnableLightBox()` wired to Alpaca `calibratoron`/`calibratoroff`
|
||||
- `DustCapInterface`: `ParkCap()` → `closecover`, `UnParkCap()` → `opencover`
|
||||
- Host / port text properties in `CONNECTION_TAB`
|
||||
- INDI device XML descriptor for indiserver auto-registration
|
||||
- `CMakeLists.txt` with `FetchContent` fallback for `nlohmann_json`
|
||||
|
||||
#### Documentation
|
||||
- `WIRING.md` — wiring diagram, BOM, component roles, MOSFET pinout, servo colour codes, ASCOM and INDI setup instructions
|
||||
|
||||
---
|
||||
|
||||
[1.1.0]: https://github.com/your-repo/flatpanel/compare/v1.0.0...v1.1.0
|
||||
[1.0.0]: https://github.com/your-repo/flatpanel/releases/tag/v1.0.0
|
||||
275
README.md
Normal file
275
README.md
Normal 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 (0–255) via logic-level MOSFET |
|
||||
| **Motorised cover** | MG995 servo rotates panel in/out of the telescope light path |
|
||||
| **Dew heater** | 12V resistive element, 0–100% PWM — keeps the diffuser clear |
|
||||
| **WiFi mode** | Native ASCOM Alpaca REST API on port 11111; auto-discovered via mDNS and UDP broadcast |
|
||||
| **USB mode** | Alnitak serial protocol at 9600 baud — plug-and-play with N.I.N.A., SGPro, APT and `indi_flipflat` |
|
||||
| **Both simultaneously** | WiFi and USB are active at the same time in a single firmware build |
|
||||
| **ASCOM** | Use ASCOM Remote (free, official) — no custom COM driver required |
|
||||
| **INDI** | Custom `indi_esp32_flatpanel` driver with WiFi and USB connection modes |
|
||||
| **Desktop app** | Dark-themed PyQt6 controller for direct manual operation |
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
flatpanel/
|
||||
├── firmware/ ESP32 firmware (PlatformIO / Arduino)
|
||||
│ ├── platformio.ini
|
||||
│ └── src/
|
||||
│ ├── config.h ← edit this first
|
||||
│ └── main.cpp
|
||||
├── indi-driver/ INDI driver (C++, Linux)
|
||||
│ ├── CMakeLists.txt
|
||||
│ ├── indi_esp32_flatpanel.h
|
||||
│ ├── indi_esp32_flatpanel.cpp
|
||||
│ └── indi_esp32_flatpanel.xml
|
||||
├── controller/ Python desktop controller (all platforms)
|
||||
│ ├── flatpanel_controller.py
|
||||
│ └── requirements.txt
|
||||
├── docs/
|
||||
│ ├── system-diagram.svg Block diagram — open in any browser
|
||||
│ ├── wiring-diagram.svg Colour-coded wiring — open in any browser
|
||||
│ └── BUILD_NOTES.md Step-by-step assembly guide
|
||||
├── WIRING.md Quick-reference wiring + BOM
|
||||
├── README.md This file
|
||||
└── CHANGELOG.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hardware required
|
||||
|
||||
| Component | Role | Pin |
|
||||
|-----------|------|-----|
|
||||
| Arduino Nano ESP32 | Main controller | — |
|
||||
| MG995 servo | Cover open/close | D9 |
|
||||
| IRLZ44N MOSFET × 2 | LED brightness + dew heater | D3, D5 |
|
||||
| LM2596 buck module | 12V → 5V for ESP32 + servo | — |
|
||||
| 12V PSU ≥ 4A | Powers everything | — |
|
||||
| White LED strip 12V (Ra ≥ 90) | Flat-field illumination | — |
|
||||
| Frosted acrylic 3mm | Light diffuser | — |
|
||||
| Dew heater element 12V ~24Ω | Anti-dew resistive heater | — |
|
||||
| 100Ω resistor × 2 | MOSFET gate series | — |
|
||||
| 10kΩ resistor × 2 | MOSFET gate pull-down | — |
|
||||
|
||||
See [docs/BUILD_NOTES.md](docs/BUILD_NOTES.md) for the full BOM, dew heater sizing table, and step-by-step assembly instructions.
|
||||
See [docs/wiring-diagram.svg](docs/wiring-diagram.svg) for the colour-coded wiring diagram.
|
||||
|
||||
---
|
||||
|
||||
## Quick-start
|
||||
|
||||
### 1 Configure the firmware
|
||||
|
||||
Edit `firmware/src/config.h`:
|
||||
|
||||
```cpp
|
||||
#define WIFI_SSID "your_network"
|
||||
#define WIFI_PASSWORD "your_password"
|
||||
|
||||
// Tune to your mechanical stops after assembly:
|
||||
#define SERVO_CLOSED 0 // panel in flat position
|
||||
#define SERVO_OPEN 90 // panel clear of telescope
|
||||
|
||||
// Set false when using USB/Alnitak mode to keep the serial line clean:
|
||||
#define SERIAL_DEBUG true
|
||||
```
|
||||
|
||||
### 2 Flash
|
||||
|
||||
```bash
|
||||
cd flatpanel
|
||||
pio run -t upload # PlatformIO CLI
|
||||
# or use the PlatformIO IDE extension in VS Code
|
||||
```
|
||||
|
||||
### 3 Verify
|
||||
|
||||
Open a serial monitor at **9600 baud**.
|
||||
You should see the ESP32's IP address printed on boot.
|
||||
Browse to:
|
||||
|
||||
```
|
||||
http://esp32-flatpanel.local:11111/management/v1/configureddevices
|
||||
```
|
||||
|
||||
Expected response contains `"DeviceType":"CoverCalibrator"`.
|
||||
|
||||
---
|
||||
|
||||
## Connection modes
|
||||
|
||||
### WiFi — ASCOM Alpaca (no driver needed)
|
||||
|
||||
The ESP32 **is** an Alpaca server. Use **ASCOM Remote** as the bridge to ASCOM COM:
|
||||
|
||||
1. Install [ASCOM Platform 6.6+](https://ascom-standards.org/Downloads/Index.htm) and [ASCOM Remote](https://github.com/ASCOMInitiative/ASCOM.Remote/releases).
|
||||
2. In ASCOM Remote, add a **CoverCalibrator** device → host `esp32-flatpanel.local`, port `11111`, device `0`.
|
||||
3. In N.I.N.A. / SGPro / APT, choose **ASCOM CoverCalibrator** and select the ASCOM Remote device.
|
||||
|
||||
The ESP32 is also discovered automatically by software that supports Alpaca UDP discovery (N.I.N.A. 3+, Cartes du Ciel).
|
||||
|
||||
### WiFi — INDI
|
||||
|
||||
```bash
|
||||
# Build and install the custom driver (once)
|
||||
cd flatpanel/indi-driver
|
||||
cmake -B build -DCMAKE_INSTALL_PREFIX=/usr
|
||||
cmake --build build -j$(nproc)
|
||||
sudo cmake --install build
|
||||
|
||||
# Start (KStars/Ekos loads it automatically after install)
|
||||
indiserver indi_esp32_flatpanel
|
||||
```
|
||||
|
||||
In Ekos: select **ESP32 FlatPanel**, set Connection Mode to **WiFi / Alpaca**, enter the host and port.
|
||||
|
||||
### USB — Alnitak (plug-and-play)
|
||||
|
||||
Set `SERIAL_DEBUG false` in `config.h` and reflash. Then connect via USB-C.
|
||||
|
||||
The firmware speaks the standard Alnitak protocol so existing software works without any driver:
|
||||
|
||||
| Software | Built-in support |
|
||||
|----------|-----------------|
|
||||
| N.I.N.A. | ✅ Alnitak / Flip-Flat |
|
||||
| Sequence Generator Pro | ✅ Alnitak |
|
||||
| Astro Photography Tool | ✅ Alnitak |
|
||||
| INDI (`indi_flipflat`) | ✅ ships with INDI |
|
||||
| Our INDI driver | ✅ USB mode in Connection tab |
|
||||
|
||||
> **Dew heater via USB:** The standard Alnitak protocol has no dew heater concept.
|
||||
> Our INDI driver and the Python controller use custom extension commands `>T` (set) / `>U` (get) that are silently ignored by other software.
|
||||
|
||||
### Desktop controller app
|
||||
|
||||
Works in both WiFi and USB mode. Dark-themed for night use.
|
||||
|
||||
```bash
|
||||
pip install PyQt6 requests pyserial
|
||||
python flatpanel/controller/flatpanel_controller.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alpaca API reference
|
||||
|
||||
Base URL: `http://<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 0–255 |
|
||||
| GET | `maxbrightness` | — | Always 255 |
|
||||
| GET | `dewheater` | — | Current dew heater 0–100% *(custom)* |
|
||||
| PUT | `opencover` | — | Rotate panel out of light path |
|
||||
| PUT | `closecover` | — | Rotate panel into flat position |
|
||||
| PUT | `haltcover` | — | Stop cover movement |
|
||||
| PUT | `calibratoron` | `Brightness=N` | Turn on LED at brightness N |
|
||||
| PUT | `calibratoroff` | — | Turn off LED |
|
||||
| PUT | `dewheater` | `Percentage=N` | Set dew heater 0–100% *(custom)* |
|
||||
| PUT | `action` | `Action=SetDewHeater`<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 0–255 | `*B19128` |
|
||||
| `>J000` | Get brightness | `*J19128` |
|
||||
| `>L000` | Light on (keep current brightness) | `*L19000` |
|
||||
| `>D000` | Light off | `*D19000` |
|
||||
| `>O000` | Open cover | `*O19000` |
|
||||
| `>C000` | Close cover | `*C19000` |
|
||||
| `>H000` | Halt cover | `*H19000` |
|
||||
| `>T050` | **Set dew heater 50%** *(custom)* | `*T19050` |
|
||||
| `>U000` | **Get dew heater %** *(custom)* | `*U19050` |
|
||||
|
||||
Motor state in `>S000` response: `0`=unknown/moving · `1`=closed · `2`=open
|
||||
Light state: `0`=off · `1`=on
|
||||
|
||||
---
|
||||
|
||||
## Pin assignments
|
||||
|
||||
| D-pin | GPIO | Function | Config define |
|
||||
|-------|------|----------|---------------|
|
||||
| D9 | — | MG995 servo signal | `SERVO_PIN` |
|
||||
| D3 | — | LED MOSFET gate | `LED_PWM_PIN` |
|
||||
| D5 | — | Dew heater MOSFET gate | `DEW_HEATER_PIN` |
|
||||
|
||||
All pins use the Arduino Nano ESP32 D-number scheme.
|
||||
LEDC channel 0 → LED · channel 1 → dew heater.
|
||||
|
||||
---
|
||||
|
||||
## Building the INDI driver
|
||||
|
||||
**Dependencies:** `libindi-dev`, `libcurl4-openssl-dev`, `nlohmann-json3-dev`, `cmake`
|
||||
|
||||
```bash
|
||||
sudo apt install libindi-dev libcurl4-openssl-dev nlohmann-json3-dev cmake build-essential
|
||||
|
||||
cd flatpanel/indi-driver
|
||||
cmake -B build -DCMAKE_INSTALL_PREFIX=/usr
|
||||
cmake --build build -j$(nproc)
|
||||
sudo cmake --install build
|
||||
```
|
||||
|
||||
If `nlohmann-json3-dev` is unavailable, CMake will fetch it automatically via `FetchContent`.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
|---------|-----|
|
||||
| No boot message on Serial | Check VIN wiring; try USB-C direct to PC (bypasses LM2596) |
|
||||
| Servo twitches constantly | LM2596 sagging; re-measure 5V under servo load |
|
||||
| LED stays on at 0% | Missing 10kΩ gate pull-down on MOSFET-A |
|
||||
| Alnitak commands ignored | Set `SERIAL_DEBUG false`, reflash |
|
||||
| `Connection refused` on port 11111 | ESP32 not yet connected to WiFi; check credentials |
|
||||
| Dew heater not warming | Verify MOSFET-B drain voltage ≈ 0V when active; check element resistance |
|
||||
| Python app import error | `pip install PyQt6 requests pyserial` |
|
||||
| INDI build fails | `sudo apt install libindi-dev` |
|
||||
|
||||
---
|
||||
|
||||
## Diagrams
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [docs/system-diagram.svg](docs/system-diagram.svg) | Architecture block diagram |
|
||||
| [docs/wiring-diagram.svg](docs/wiring-diagram.svg) | Colour-coded wiring diagram |
|
||||
|
||||
Open either file in a web browser — no additional software needed.
|
||||
|
||||
---
|
||||
|
||||
## Licence
|
||||
|
||||
Released under the **MIT Licence** — see `LICENCE` file.
|
||||
ASCOM and Alpaca are trademarks of the ASCOM Initiative.
|
||||
INDI is maintained by the INDI developers at indilib.org.
|
||||
177
WIRING.md
Normal file
177
WIRING.md
Normal 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
|
||||
```
|
||||
909
controller/flatpanel_controller.py
Normal file
909
controller/flatpanel_controller.py
Normal 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()
|
||||
3
controller/requirements.txt
Normal file
3
controller/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
PyQt6>=6.4.0
|
||||
requests>=2.28.0
|
||||
pyserial>=3.5
|
||||
289
docs/BUILD_NOTES.md
Normal file
289
docs/BUILD_NOTES.md
Normal 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 80–150mm (**recommended starting point**) |
|
||||
| 16 Ω | 9 W | Newtonians 150–250mm |
|
||||
| 12 Ω | 12 W | Large apertures 250mm+ |
|
||||
|
||||
> PWM control means the average power is `P × (percent/100)`, so it is fine
|
||||
> to overspec the element and run it at 30–50%.
|
||||
|
||||
---
|
||||
|
||||
## Tools Required
|
||||
|
||||
- Soldering iron + solder (60/40 or lead-free)
|
||||
- Wire strippers and cutters
|
||||
- Multimeter (essential for LM2596 setup)
|
||||
- Hot glue gun or epoxy (for mechanical parts)
|
||||
- Small Phillips screwdriver
|
||||
- Isopropyl alcohol + cotton swabs (flux cleanup)
|
||||
- PlatformIO CLI or IDE for firmware flashing
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Build Instructions
|
||||
|
||||
### Phase 1 — LM2596 Calibration ⚠ Do this FIRST before connecting anything else
|
||||
|
||||
1. Connect **only** the 12V PSU to the LM2596 `IN+` and `IN−` terminals.
|
||||
2. Set your multimeter to DC voltage, probes on `OUT+` and `OUT−`.
|
||||
3. Adjust the LM2596 trimmer pot (small brass screw):
|
||||
- Clockwise → voltage **increases**
|
||||
- Counter-clockwise → voltage **decreases**
|
||||
4. Dial to **5.00 V ± 0.05 V**.
|
||||
5. Disconnect the PSU. The buck is now calibrated and safe to use with the ESP32 and servo.
|
||||
|
||||
> Connecting the MG995 to an unregulated or high-voltage supply will burn out
|
||||
> the servo electronics instantly.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Stripboard Layout
|
||||
|
||||
Recommended wire colour convention:
|
||||
|
||||
| Colour | Rail |
|
||||
|--------|------|
|
||||
| Red | 12 V positive |
|
||||
| Orange | 5 V positive |
|
||||
| Black | GND |
|
||||
| Blue | PWM signal |
|
||||
| Yellow | Servo signal |
|
||||
| Grey | Load output (LED−, Heater−) |
|
||||
|
||||
Lay three horizontal bus strips along the top of the board:
|
||||
- `12V` bus (red)
|
||||
- `5V` bus (orange)
|
||||
- `GND` bus (black)
|
||||
|
||||
Place both MOSFETs at the bottom edge of the board, flat faces to the right, legs bent 90° and inserted vertically:
|
||||
|
||||
```
|
||||
IRLZ44N (flat face → you)
|
||||
Pin 1: Gate ← 100Ω from ESP32 PWM, 10kΩ to GND
|
||||
Pin 2: Drain → load negative (LED− or Heater−)
|
||||
Pin 3: Source → GND bus
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — MOSFET-A (LED Panel) Wiring
|
||||
|
||||
1. Insert MOSFET-A into the stripboard.
|
||||
2. Solder a 100 Ω resistor between **Gate** and the pad that will connect to **D3** on the ESP32.
|
||||
3. Solder a 10 kΩ resistor between **Gate** and the **GND bus**.
|
||||
4. Solder **Source** to the **GND bus**.
|
||||
5. Run a wire from **Drain** to the negative lead of the LED strip (mark it grey/black).
|
||||
6. Solder a wire from the positive lead of the LED strip to the **12V bus**.
|
||||
|
||||
> Test at this point: apply a 3.3V signal to D3 connection. The LED strip should illuminate.
|
||||
> If it stays on at 0V, check the 10kΩ pull-down is correctly placed Gate→GND.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — MOSFET-B (Dew Heater) Wiring
|
||||
|
||||
Repeat Phase 3 for MOSFET-B, using **D5** instead of D3, and the heating element instead of the LED strip.
|
||||
|
||||
The heater element positive lead connects to the 12V bus; negative to MOSFET-B drain.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — MG995 Servo Wiring
|
||||
|
||||
The MG995 uses a standard 3-wire connector:
|
||||
|
||||
| Wire colour | Connection |
|
||||
|-------------|------------|
|
||||
| Brown or Black | GND bus |
|
||||
| Red | **5V bus** (from LM2596 — NOT 3.3V or raw 12V) |
|
||||
| Orange or Yellow | D9 on ESP32 |
|
||||
|
||||
> The MG995 draws up to 1A under load. Always power it from the regulated 5V
|
||||
> rail. If the servo twitches or buzzes after connecting, re-check the LM2596
|
||||
> output voltage — it should not drop below 4.8V under servo load.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — ESP32 Power and Ground
|
||||
|
||||
1. Run a wire from the 5V bus to **VIN** on the Arduino Nano ESP32.
|
||||
2. Run a wire from the GND bus to **GND** on the ESP32.
|
||||
3. Do **not** power the ESP32 from the 3.3V pin — that is an output, not an input.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — Firmware Configuration and Flash
|
||||
|
||||
1. Open `firmware/src/config.h` and edit:
|
||||
```cpp
|
||||
#define WIFI_SSID "your_network_name"
|
||||
#define WIFI_PASSWORD "your_password"
|
||||
```
|
||||
2. Tune servo angles (power the servo and watch physical movement):
|
||||
```cpp
|
||||
#define SERVO_CLOSED 0 // panel in flat position
|
||||
#define SERVO_OPEN 90 // panel clear of telescope
|
||||
```
|
||||
3. For USB-only operation (no WiFi debug output on the serial line):
|
||||
```cpp
|
||||
#define SERIAL_DEBUG false
|
||||
```
|
||||
4. Flash via PlatformIO:
|
||||
```
|
||||
cd flatpanel
|
||||
pio run -t upload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 8 — First Power-On Checks (ordered sequence)
|
||||
|
||||
Work through this list in order. Stop if any step fails.
|
||||
|
||||
**Step 1 — Bench test buck converter**
|
||||
- Measure 5V bus at LM2596 OUT+ with no loads connected.
|
||||
- Must read 4.9–5.1V. Adjust if needed.
|
||||
|
||||
**Step 2 — ESP32 power**
|
||||
- Connect ESP32 to 5V bus and GND bus only.
|
||||
- Open a serial monitor at 9600 baud. You should see the boot message and a WiFi IP address printed.
|
||||
- If nothing appears: check VIN wiring.
|
||||
|
||||
**Step 3 — WiFi / Alpaca API verification**
|
||||
- Open a web browser and navigate to: `http://<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
282
docs/system-diagram.svg
Normal 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 0–255
|
||||
</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 0–100 %
|
||||
</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
313
docs/wiring-diagram.svg
Normal 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 & 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
14
firmware/platformio.ini
Normal 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
58
firmware/src/config.h
Normal 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
515
firmware/src/main.cpp
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
39
indi-driver/CMakeLists.txt
Normal file
39
indi-driver/CMakeLists.txt
Normal 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})
|
||||
581
indi-driver/indi_esp32_flatpanel.cpp
Normal file
581
indi-driver/indi_esp32_flatpanel.cpp
Normal 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);
|
||||
}
|
||||
88
indi-driver/indi_esp32_flatpanel.h
Normal file
88
indi-driver/indi_esp32_flatpanel.h
Normal 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();
|
||||
};
|
||||
9
indi-driver/indi_esp32_flatpanel.xml
Normal file
9
indi-driver/indi_esp32_flatpanel.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue