From cec6f914ea93dfc87eb26f7498bdb60037784c21 Mon Sep 17 00:00:00 2001 From: hjoungjoo <149982795+hjoungjoo@users.noreply.github.com> Date: Sat, 27 Jun 2026 07:25:41 +0900 Subject: [PATCH 1/3] Add optional INDI mount control --- default_config.json | 3 + docs/mf_indi_mount_install_en.md | 129 ++++++ docs/mf_indi_mount_install_ko.md | 129 ++++++ python/PiFinder/main.py | 42 ++ python/PiFinder/mountcontrol_indi.py | 588 +++++++++++++++++++++++++++ python/PiFinder/ui/callbacks.py | 8 + python/PiFinder/ui/menu_structure.py | 17 + python/PiFinder/ui/object_details.py | 65 +++ scripts/install_indi_mount.sh | 168 ++++++++ 9 files changed, 1149 insertions(+) create mode 100644 docs/mf_indi_mount_install_en.md create mode 100644 docs/mf_indi_mount_install_ko.md create mode 100644 python/PiFinder/mountcontrol_indi.py create mode 100755 scripts/install_indi_mount.sh diff --git a/default_config.json b/default_config.json index 7684bff0f..30bccd8a1 100644 --- a/default_config.json +++ b/default_config.json @@ -9,6 +9,9 @@ "search_input_method": "multi_tap", "screen_direction": "right", "mount_type": "Alt/Az", + "mount_control": false, + "mount_control_indi_host": "localhost", + "mount_control_indi_port": 7624, "solver_debug": 0, "sleep_timeout": "30s", "screen_off_timeout": "Off", diff --git a/docs/mf_indi_mount_install_en.md b/docs/mf_indi_mount_install_en.md new file mode 100644 index 000000000..5def30336 --- /dev/null +++ b/docs/mf_indi_mount_install_en.md @@ -0,0 +1,129 @@ +# MF PiFinder INDI Mount Control + +This document covers the optional INDI mount-control work for Raspberry Pi 4 and Raspberry Pi 5 Bookworm 64-bit builds. + +The feature is disabled by default. Normal PiFinder installs do not import PyIndi or start the INDI mount-control process unless `mount_control` is enabled in the PiFinder config. + +The installer has been validated on a Raspberry Pi 4 Model B running Bookworm 64-bit. Raspberry Pi 5 and CM5 use the same Bookworm 64-bit packages and aarch64 build path, and the script does not contain Pi 4-only paths or model-specific branches. + +## Status + +INDI mount control is experimental. Test with the INDI Telescope Simulator first, then test with the real mount in a safe indoor setup before using it under the sky. + +The first integrated scope includes: + +- INDI server connection through PyIndi +- telescope/mount device detection +- location and UTC time sync from PiFinder +- mount sync from PiFinder plate-solved RA/Dec +- GoTo for the object currently shown in Object Details +- stop command +- small manual RA/Dec offset moves + +Automatic target refinement, drift compensation, and alignment-subsystem management from the older reference branch are not enabled in this first modular port. + +## Install INDI Support + +Run the dedicated installer from the PiFinder checkout: + +```bash +cd ~/PiFinder +bash scripts/install_indi_mount.sh +``` + +The script installs INDI, INDI third-party drivers, PyIndi, INDI Web Manager, and Chrony GPS time support. It stops the `pifinder` service while compiling and starts it again at the end. + +INDI Web Manager dependencies are pinned to `FastAPI 0.103.2`, `Starlette 0.27.0`, `Uvicorn 0.23.2`, and `AnyIO 3.7.1`. Newer Starlette releases changed the template response call signature used by this INDI Web Manager branch, which can make the root Web UI return `500 Internal Server Error`. + +Useful environment overrides: + +```bash +INDI_VERSION=v2.1.6 INDI_3RDPARTY_VERSION=v2.1.6.2 JOBS=2 bash scripts/install_indi_mount.sh +``` + +`JOBS=2` is the recommended default on Raspberry Pi 4 to keep memory use conservative. On Raspberry Pi 5 or CM5, `JOBS=3` or `JOBS=4` can reduce build time if cooling and power are stable. + +## Configure The Mount Driver + +Open INDI Web Manager: + +```text +http://pifinder.local:8624 +``` + +If mDNS does not resolve, use the PiFinder IP address: + +```text +http://:8624 +``` + +Create a profile, choose the correct telescope driver, enable Auto Start and Auto Connect if desired, then start the profile. Common drivers include EQMod, LX200, iOptron, Celestron, and Telescope Simulator. + +## Enable PiFinder Control + +On the PiFinder UI: + +```text +Settings > Experimental > Mount Control > On +``` + +Changing this option restarts PiFinder so the optional `MountControl` process can start or stop cleanly. + +Advanced config keys in `default_config.json`: + +```json +"mount_control": false, +"mount_control_indi_host": "localhost", +"mount_control_indi_port": 7624 +``` + +## Object Details Key Map + +When Mount Control is enabled, numeric keys on the Object Details screen send mount commands: + +| Key | Action | +| --- | --- | +| 0 | Stop mount | +| 1 | Initialize INDI connection and sync if PiFinder has a solve | +| 2 | Move south by the current step size | +| 3 | Decrease step size | +| 4 | Move west by the current step size | +| 5 | GoTo the displayed object | +| 6 | Move east by the current step size | +| 7 | Sync mount to the current PiFinder solved position | +| 8 | Move north by the current step size | +| 9 | Increase step size | + +Manual movement is implemented as a small RA/Dec GoTo offset from the current mount coordinates. The default step size is 1 degree; key `3` halves it and key `9` doubles it within safe bounds. + +## Logs And Status + +PiFinder logs mount-control messages under `MountControl.Indi`. + +A small status file is written here: + +```text +~/PiFinder_data/mount_control_status.json +``` + +Useful service checks: + +```bash +systemctl status indiwebmanager.service +systemctl status pifinder.service +journalctl -u indiwebmanager.service -n 100 +tail -n 100 ~/PiFinder_data/pifinder.log +``` + +## Safe Test Flow + +1. Install INDI support. +2. Start the Telescope Simulator in INDI Web Manager. +3. Enable PiFinder Mount Control. +4. Open any Object Details screen. +5. Press `1` to initialize. +6. After PiFinder has a solve, press `7` to sync. +7. Press `5` to send GoTo. +8. Press `0` to verify stop behavior. + +Only move to a real mount after simulator behavior is understood. diff --git a/docs/mf_indi_mount_install_ko.md b/docs/mf_indi_mount_install_ko.md new file mode 100644 index 000000000..fc436fc07 --- /dev/null +++ b/docs/mf_indi_mount_install_ko.md @@ -0,0 +1,129 @@ +# MF PiFinder INDI 마운트 제어 + +이 문서는 Raspberry Pi 4와 Raspberry Pi 5 Bookworm 64-bit 빌드에서 사용할 수 있는 선택형 INDI 마운트 제어 작업을 설명합니다. + +이 기능은 기본값이 꺼짐입니다. `mount_control` 설정을 켜기 전까지 일반 PiFinder 설치에서는 PyIndi를 import하지 않고 INDI 마운트 제어 프로세스도 시작하지 않습니다. + +설치 스크립트는 Raspberry Pi 4 Model B Bookworm 64-bit에서 검증했습니다. Pi 5와 CM5도 같은 Bookworm 64-bit 패키지와 aarch64 빌드 경로를 사용하며, 스크립트에는 Pi 4 전용 경로나 모델별 분기가 없습니다. + +## 현재 범위 + +INDI 마운트 제어는 실험 기능입니다. 먼저 INDI Telescope Simulator로 테스트하고, 실제 마운트는 실내의 안전한 상태에서 충분히 확인한 뒤 야외에서 사용하세요. + +이번 1차 통합 범위는 다음과 같습니다. + +- PyIndi를 통한 INDI 서버 연결 +- telescope/mount 장치 자동 감지 +- PiFinder의 위치와 UTC 시간 동기화 +- PiFinder plate-solve RA/Dec 기준 마운트 Sync +- Object Details 화면에 표시된 대상 GoTo +- Stop 명령 +- 작은 RA/Dec 오프셋 기반 수동 이동 + +구버전 참고 브랜치에 있던 자동 target refinement, drift compensation, INDI alignment subsystem 관리 기능은 이번 1차 모듈화 포트에는 포함하지 않았습니다. + +## INDI 지원 설치 + +PiFinder 체크아웃에서 전용 설치 스크립트를 실행합니다. + +```bash +cd ~/PiFinder +bash scripts/install_indi_mount.sh +``` + +이 스크립트는 INDI, INDI third-party 드라이버, PyIndi, INDI Web Manager, Chrony GPS 시간 동기화 지원을 설치합니다. 컴파일 중에는 `pifinder` 서비스를 잠시 멈추고, 완료 후 다시 시작합니다. + +INDI Web Manager는 현재 `FastAPI 0.103.2`, `Starlette 0.27.0`, `Uvicorn 0.23.2`, `AnyIO 3.7.1` 조합으로 고정되어 있습니다. 최신 Starlette 계열에서는 INDI Web Manager의 기존 템플릿 호출 방식과 맞지 않아 Web UI 루트 페이지가 `500 Internal Server Error`를 반환할 수 있습니다. + +필요하면 환경 변수로 버전과 빌드 병렬 수를 바꿀 수 있습니다. + +```bash +INDI_VERSION=v2.1.6 INDI_3RDPARTY_VERSION=v2.1.6.2 JOBS=2 bash scripts/install_indi_mount.sh +``` + +Pi 4에서는 메모리 여유를 위해 기본 `JOBS=2`를 권장합니다. Pi 5나 CM5에서는 냉각과 전원 상태가 안정적이면 `JOBS=3` 또는 `JOBS=4`로 빌드 시간을 줄일 수 있습니다. + +## 마운트 드라이버 설정 + +INDI Web Manager를 엽니다. + +```text +http://pifinder.local:8624 +``` + +mDNS 이름이 동작하지 않으면 PiFinder IP 주소를 사용합니다. + +```text +http://:8624 +``` + +Profile을 만들고 사용하는 마운트에 맞는 telescope driver를 선택합니다. 필요하면 Auto Start와 Auto Connect를 켠 뒤 profile을 시작합니다. 흔한 드라이버는 EQMod, LX200, iOptron, Celestron, Telescope Simulator입니다. + +## PiFinder 제어 켜기 + +PiFinder UI에서 다음 메뉴로 이동합니다. + +```text +Settings > Experimental > Mount Control > On +``` + +이 값을 변경하면 선택형 `MountControl` 프로세스를 깨끗하게 시작하거나 종료하기 위해 PiFinder가 재시작됩니다. + +고급 설정 키는 `default_config.json`에 있습니다. + +```json +"mount_control": false, +"mount_control_indi_host": "localhost", +"mount_control_indi_port": 7624 +``` + +## Object Details 숫자 키 맵 + +Mount Control이 켜져 있으면 Object Details 화면의 숫자 키가 마운트 명령을 보냅니다. + +| 키 | 동작 | +| --- | --- | +| 0 | 마운트 정지 | +| 1 | INDI 연결 초기화, PiFinder solve가 있으면 Sync | +| 2 | 현재 step 크기만큼 South 이동 | +| 3 | step 크기 줄이기 | +| 4 | 현재 step 크기만큼 West 이동 | +| 5 | 현재 표시 중인 대상 GoTo | +| 6 | 현재 step 크기만큼 East 이동 | +| 7 | 현재 PiFinder solve 위치로 마운트 Sync | +| 8 | 현재 step 크기만큼 North 이동 | +| 9 | step 크기 키우기 | + +수동 이동은 현재 마운트 RA/Dec 좌표에서 작은 GoTo 오프셋을 보내는 방식입니다. 기본 step은 1도이고, `3`은 절반으로 줄이며 `9`는 두 배로 키웁니다. + +## 로그와 상태 확인 + +PiFinder 로그에는 `MountControl.Indi` 이름으로 마운트 제어 로그가 남습니다. + +상태 파일은 다음 위치에 기록됩니다. + +```text +~/PiFinder_data/mount_control_status.json +``` + +확인에 유용한 명령은 다음과 같습니다. + +```bash +systemctl status indiwebmanager.service +systemctl status pifinder.service +journalctl -u indiwebmanager.service -n 100 +tail -n 100 ~/PiFinder_data/pifinder.log +``` + +## 안전 테스트 순서 + +1. INDI 지원을 설치합니다. +2. INDI Web Manager에서 Telescope Simulator를 시작합니다. +3. PiFinder Mount Control을 켭니다. +4. 아무 대상의 Object Details 화면을 엽니다. +5. `1`을 눌러 초기화합니다. +6. PiFinder solve가 잡힌 뒤 `7`을 눌러 Sync합니다. +7. `5`를 눌러 GoTo를 보냅니다. +8. `0`으로 Stop 동작을 확인합니다. + +시뮬레이터 동작을 이해한 뒤 실제 마운트 테스트로 넘어가세요. diff --git a/python/PiFinder/main.py b/python/PiFinder/main.py index 8754ff10d..dd3b8fe9e 100644 --- a/python/PiFinder/main.py +++ b/python/PiFinder/main.py @@ -362,6 +362,7 @@ def main( alignment_command_queue: Queue = Queue() alignment_response_queue: Queue = Queue() ui_queue: Queue = Queue() + mountcontrol_queue: Queue = Queue() # init queues for logging keyboard_logqueue: Queue = log_helper.get_queue() @@ -374,6 +375,7 @@ def main( imu_logqueue: Queue = log_helper.get_queue() battery_logqueue: Queue = log_helper.get_queue() sound_logqueue: Queue = log_helper.get_queue() + mountcontrol_logqueue: Queue = log_helper.get_queue() # Refuse to start if another instance is already running. A second copy # would otherwise boot and let its subsystems (web/pos-server ports, cedar @@ -405,6 +407,7 @@ def main( "align_response": alignment_response_queue, "gps": gps_queue, "integrator": integrator_command_queue, + "mountcontrol": mountcontrol_queue, } cfg = config.Config() @@ -617,6 +620,37 @@ def main( ) posserver_process.start() + mountcontrol_process = None + if cfg.get_option("mount_control", False): + console.write(" INDI Mount") + logger.info(" INDI Mount") + console.update() + try: + from PiFinder import mountcontrol_indi + + mountcontrol_process = Process( + name="MountControl", + target=mountcontrol_indi.run, + args=( + mountcontrol_queue, + console_queue, + shared_state, + mountcontrol_logqueue, + ), + kwargs={ + "indi_host": cfg.get_option( + "mount_control_indi_host", "localhost" + ), + "indi_port": int( + cfg.get_option("mount_control_indi_port", 7624) + ), + }, + ) + mountcontrol_process.start() + except Exception: + logger.exception("Could not start INDI mount-control process") + console.write("INDI mount failed") + # Initialize Catalogs console.write(" Catalogs") logger.info(" Catalogs") @@ -1028,6 +1062,14 @@ def main( logger.info("\tPos Server...") posserver_process.join() + if mountcontrol_process is not None: + logger.info("\tINDI Mount...") + mountcontrol_queue.put({"type": "shutdown"}) + mountcontrol_process.join(timeout=3) + if mountcontrol_process.is_alive(): + mountcontrol_process.terminate() + mountcontrol_process.join() + logger.info("\tGPS...") gps_process.terminate() diff --git a/python/PiFinder/mountcontrol_indi.py b/python/PiFinder/mountcontrol_indi.py new file mode 100644 index 000000000..513b56cf9 --- /dev/null +++ b/python/PiFinder/mountcontrol_indi.py @@ -0,0 +1,588 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Small INDI mount-control bridge for PiFinder. + +The feature is intentionally optional: PyIndi is imported defensively and this +module is only started when ``mount_control`` is enabled in the PiFinder config. +""" + +from __future__ import annotations + +import json +import logging +import queue +import time +from datetime import timezone +from multiprocessing import Queue +from typing import Any, Optional + +from PiFinder import utils +from PiFinder.multiproclogging import MultiprocLogging + +try: + import PyIndi # type: ignore[import-untyped] +except ImportError: # pragma: no cover - exercised only on INDI installs + PyIndi = None + + +logger = logging.getLogger("MountControl.Indi") +clientlogger = logging.getLogger("MountControl.Indi.Client") + +STATUS_FILE = utils.data_dir / "mount_control_status.json" +DEFAULT_STEP_DEGREES = 1.0 +MIN_STEP_DEGREES = 0.05 +MAX_STEP_DEGREES = 10.0 + + +def _write_status(state: str, message: str = "", **extra: Any) -> None: + """Persist a compact mount-control status snapshot for logs/web/debug.""" + try: + utils.create_path(utils.data_dir) + payload = { + "state": state, + "message": message, + "updated": time.time(), + } + payload.update(extra) + with open(STATUS_FILE, "w", encoding="utf-8") as status_out: + json.dump(payload, status_out, indent=2, sort_keys=True) + except Exception: + logger.exception("Could not write mount-control status") + + +if PyIndi is not None: + + class PiFinderIndiClient(PyIndi.BaseClient): # type: ignore[misc] + """Minimal INDI client that finds a telescope-like device.""" + + def __init__(self, mount_control=None): + super().__init__() + self.telescope_device = None + self.mount_control = mount_control + + def get_telescope_device(self): + return self.telescope_device + + def _wait_for_property(self, device, property_name: str, timeout: float = 5.0): + start_time = time.time() + while time.time() - start_time < timeout: + prop = device.getProperty(property_name) + if prop: + return prop + time.sleep(0.1) + clientlogger.warning( + "Timeout waiting for property %s on %s", + property_name, + device.getDeviceName(), + ) + return None + + def set_switch( + self, device, property_name: str, element_name: str, timeout: float = 5.0 + ) -> bool: + if not self._wait_for_property(device, property_name, timeout): + return False + + switch_prop = device.getSwitch(property_name) + if not switch_prop: + clientlogger.error("Could not get switch property %s", property_name) + return False + + found = False + for i in range(len(switch_prop)): + switch = switch_prop[i] + if switch.name == element_name: + switch.s = PyIndi.ISS_ON + found = True + else: + switch.s = PyIndi.ISS_OFF + + if not found: + clientlogger.error( + "Switch element %s.%s not found", property_name, element_name + ) + return False + + self.sendNewSwitch(switch_prop) + return True + + def set_number( + self, device, property_name: str, values: dict[str, float], timeout=5.0 + ) -> bool: + if not self._wait_for_property(device, property_name, timeout): + return False + + number_prop = device.getNumber(property_name) + if not number_prop: + clientlogger.error("Could not get number property %s", property_name) + return False + + found = False + for i in range(len(number_prop)): + number = number_prop[i] + if number.name in values: + number.value = values[number.name] + found = True + + if not found: + clientlogger.error("No matching elements in %s", property_name) + return False + + self.sendNewNumber(number_prop) + return True + + def set_text( + self, device, property_name: str, values: dict[str, str], timeout=5.0 + ) -> bool: + if not self._wait_for_property(device, property_name, timeout): + return False + + text_prop = device.getText(property_name) + if not text_prop: + clientlogger.error("Could not get text property %s", property_name) + return False + + found = False + for i in range(len(text_prop)): + text = text_prop[i] + if text.name in values: + text.text = values[text.name] + found = True + + if not found: + clientlogger.error("No matching elements in %s", property_name) + return False + + self.sendNewText(text_prop) + return True + + def unpark_mount(self, device) -> bool: + if not self._wait_for_property(device, "TELESCOPE_PARK", timeout=2.0): + return True + + park_switch = device.getSwitch("TELESCOPE_PARK") + if not park_switch: + return True + + is_parked = False + for i in range(len(park_switch)): + if ( + park_switch[i].name == "PARK" + and park_switch[i].s == PyIndi.ISS_ON + ): + is_parked = True + break + + return not is_parked or self.set_switch(device, "TELESCOPE_PARK", "UNPARK") + + def enable_tracking(self, device) -> bool: + if self._wait_for_property(device, "TELESCOPE_TRACK_MODE", timeout=2.0): + self.set_switch(device, "TELESCOPE_TRACK_MODE", "TRACK_SIDEREAL") + + if self._wait_for_property(device, "TELESCOPE_TRACK_STATE", timeout=2.0): + return self.set_switch(device, "TELESCOPE_TRACK_STATE", "TRACK_ON") + return True + + def newDevice(self, device): + device_name = device.getDeviceName().lower() + if self.telescope_device is None and ( + any( + word in device_name + for word in ("telescope", "mount", "eqmod", "lx200", "celestron") + ) + or device_name == "telescope simulator" + ): + self.telescope_device = device + clientlogger.info("Telescope device detected: %s", device.getDeviceName()) + + def removeDevice(self, device): + if ( + self.telescope_device + and device.getDeviceName() == self.telescope_device.getDeviceName() + ): + clientlogger.warning("Telescope device removed: %s", device.getDeviceName()) + self.telescope_device = None + + def newNumber(self, nvp): + if nvp.name != "EQUATORIAL_EOD_COORD": + return + + ra_hours = None + dec_deg = None + for widget in nvp: + if widget.name == "RA": + ra_hours = widget.value + elif widget.name == "DEC": + dec_deg = widget.value + + if ( + self.mount_control is not None + and ra_hours is not None + and dec_deg is not None + ): + self.mount_control.set_current_position(ra_hours * 15.0, dec_deg) + + def newMessage(self, device, message): + clientlogger.info( + "INDI message from %s: %s", + device.getDeviceName(), + device.messageQueue(message), + ) + + def serverConnected(self): + clientlogger.info("Connected to INDI server") + + def serverDisconnected(self, code): + clientlogger.warning("Disconnected from INDI server: %s", code) + +else: + + class PiFinderIndiClient: # type: ignore[no-redef] + pass + + +class MountControlIndi: + """Translate PiFinder queue commands into INDI telescope commands.""" + + def __init__( + self, + mount_queue: Queue, + console_queue: Queue, + shared_state, + indi_host: str = "localhost", + indi_port: int = 7624, + ): + self.mount_queue = mount_queue + self.console_queue = console_queue + self.shared_state = shared_state + self.indi_host = indi_host + self.indi_port = indi_port + self.client: Optional[PiFinderIndiClient] = None + self.device = None + self.step_degrees = DEFAULT_STEP_DEGREES + self.current_ra: Optional[float] = None + self.current_dec: Optional[float] = None + self.connected = False + + def _console(self, message: str) -> None: + self.console_queue.put(message) + + def set_current_position(self, ra_deg: float, dec_deg: float) -> None: + self.current_ra = ra_deg % 360.0 + self.current_dec = dec_deg + _write_status( + "connected", + "Mount position updated", + ra=self.current_ra, + dec=self.current_dec, + step_degrees=self.step_degrees, + ) + + def _wait_for_device(self, timeout: float = 10.0) -> bool: + assert self.client is not None + start = time.time() + while time.time() - start < timeout: + self.device = self.client.get_telescope_device() + if self.device is not None: + return True + time.sleep(0.25) + return False + + def connect(self) -> bool: + if self.connected and self.device is not None: + return True + + if PyIndi is None: + _write_status("missing_pyindi", "PyIndi is not installed") + self._console("INDI mount\nPyIndi missing") + return False + + self.client = PiFinderIndiClient(self) + self.client.setServer(self.indi_host, self.indi_port) + _write_status( + "connecting", + f"Connecting to INDI server {self.indi_host}:{self.indi_port}", + ) + logger.info("Connecting to INDI server at %s:%s", self.indi_host, self.indi_port) + + if not self.client.connectServer(): + _write_status( + "server_unavailable", + f"Could not connect to INDI server {self.indi_host}:{self.indi_port}", + ) + self._console("INDI server\nnot found") + return False + + if not self._wait_for_device(): + _write_status("no_telescope", "No telescope/mount device detected") + self._console("INDI mount\nnot found") + return False + + assert self.device is not None + device_name = self.device.getDeviceName() + logger.info("Using INDI telescope device: %s", device_name) + + if self.client._wait_for_property(self.device, "CONNECTION", timeout=2.0): + if not self.device.isConnected(): + if not self.client.set_switch(self.device, "CONNECTION", "CONNECT"): + _write_status("device_connect_failed", f"Could not connect {device_name}") + self._console("INDI mount\nconnect failed") + return False + time.sleep(1.0) + + self.sync_location_time() + self.client.unpark_mount(self.device) + self.client.enable_tracking(self.device) + self._read_current_position() + self.connected = True + _write_status( + "connected", + f"Connected to {device_name}", + device=device_name, + step_degrees=self.step_degrees, + ra=self.current_ra, + dec=self.current_dec, + ) + self._console("INDI mount\nconnected") + return True + + def disconnect(self) -> None: + if self.client is not None: + try: + self.client.disconnectServer() + except Exception: + logger.exception("Could not disconnect from INDI server") + self.connected = False + _write_status("stopped", "Mount-control process stopped") + + def sync_location_time(self) -> None: + if self.client is None or self.device is None: + return + + try: + location = self.shared_state.location() + if location and location.lock: + values = {"LAT": location.lat, "LONG": location.lon} + if location.altitude is not None: + values["ELEV"] = location.altitude + self.client.set_number(self.device, "GEOGRAPHIC_COORD", values, timeout=1.0) + + dt = self.shared_state.datetime() + if dt is not None: + utc_dt = dt.astimezone(timezone.utc) + self.client.set_text( + self.device, + "TIME_UTC", + { + "UTC": utc_dt.replace(microsecond=0).strftime( + "%Y-%m-%dT%H:%M:%S" + ), + "OFFSET": "0", + }, + timeout=1.0, + ) + except Exception: + logger.exception("Could not sync INDI location/time") + + def _read_current_position(self) -> Optional[tuple[float, float]]: + if self.client is None or self.device is None: + return None + + if not self.client._wait_for_property( + self.device, "EQUATORIAL_EOD_COORD", timeout=2.0 + ): + return None + + coord_prop = self.device.getNumber("EQUATORIAL_EOD_COORD") + if not coord_prop: + return None + + ra_hours = None + dec_deg = None + for i in range(len(coord_prop)): + number = coord_prop[i] + if number.name == "RA": + ra_hours = number.value + elif number.name == "DEC": + dec_deg = number.value + + if ra_hours is None or dec_deg is None: + return None + + self.set_current_position(ra_hours * 15.0, dec_deg) + return self.current_ra, self.current_dec + + def sync_mount(self, ra_deg: float, dec_deg: float) -> bool: + if not self.connect() or self.client is None or self.device is None: + return False + + if not self.client.set_switch(self.device, "ON_COORD_SET", "SYNC"): + _write_status("sync_failed", "Could not set INDI SYNC mode") + return False + + if not self.client.set_number( + self.device, + "EQUATORIAL_EOD_COORD", + {"RA": (ra_deg % 360.0) / 15.0, "DEC": dec_deg}, + ): + _write_status("sync_failed", "Could not set sync coordinates") + return False + + self.client.set_switch(self.device, "ON_COORD_SET", "TRACK") + self.client.set_switch(self.device, "TELESCOPE_TRACK_STATE", "TRACK_ON") + self.set_current_position(ra_deg, dec_deg) + logger.info("Mount synced to RA %.4f Dec %.4f", ra_deg, dec_deg) + self._console("INDI mount\nsynced") + return True + + def goto_target(self, ra_deg: float, dec_deg: float) -> bool: + if not self.connect() or self.client is None or self.device is None: + return False + + if not self.client.set_switch(self.device, "ON_COORD_SET", "TRACK"): + _write_status("goto_failed", "Could not set INDI TRACK mode") + return False + + if not self.client.set_number( + self.device, + "EQUATORIAL_EOD_COORD", + {"RA": (ra_deg % 360.0) / 15.0, "DEC": dec_deg}, + ): + _write_status("goto_failed", "Could not set target coordinates") + return False + + _write_status( + "slewing", + "GoTo target command sent", + target_ra=ra_deg % 360.0, + target_dec=dec_deg, + step_degrees=self.step_degrees, + ) + logger.info("Mount GoTo RA %.4f Dec %.4f", ra_deg, dec_deg) + self._console("INDI mount\nGoTo sent") + return True + + def stop_mount(self) -> bool: + if not self.connect() or self.client is None or self.device is None: + return False + + if not self.client.set_switch(self.device, "TELESCOPE_ABORT_MOTION", "ABORT"): + _write_status("stop_failed", "Could not send abort motion") + return False + + _write_status("stopped", "Mount stop command sent") + logger.info("Mount stop command sent") + self._console("INDI mount\nstopped") + return True + + def manual_move(self, direction: str) -> bool: + if not self.connect(): + return False + + position = self._read_current_position() + if position is None: + _write_status("manual_failed", "Could not read current mount position") + self._console("INDI mount\nno position") + return False + + ra_deg, dec_deg = position + direction = direction.lower() + if direction == "north": + dec_deg = min(90.0, dec_deg + self.step_degrees) + elif direction == "south": + dec_deg = max(-90.0, dec_deg - self.step_degrees) + elif direction == "east": + ra_deg = (ra_deg + self.step_degrees) % 360.0 + elif direction == "west": + ra_deg = (ra_deg - self.step_degrees) % 360.0 + else: + logger.warning("Unknown manual mount direction: %s", direction) + return False + + logger.info("Manual %s move by %.2f degrees", direction, self.step_degrees) + return self.goto_target(ra_deg, dec_deg) + + def change_step(self, multiplier: float) -> None: + self.step_degrees = max( + MIN_STEP_DEGREES, + min(MAX_STEP_DEGREES, self.step_degrees * multiplier), + ) + _write_status( + "connected" if self.connected else "idle", + f"Step size {self.step_degrees:.2f} deg", + step_degrees=self.step_degrees, + ra=self.current_ra, + dec=self.current_dec, + ) + self._console(f"INDI step\n{self.step_degrees:.2f} deg") + + def handle_command(self, command: Any) -> bool: + if not isinstance(command, dict): + logger.warning("Ignoring mount-control command: %r", command) + return True + + command_type = command.get("type") + if command_type == "shutdown": + return False + if command_type == "init": + self.connect() + elif command_type == "sync": + self.sync_mount(float(command["ra"]), float(command["dec"])) + elif command_type == "goto_target": + self.goto_target(float(command["ra"]), float(command["dec"])) + elif command_type == "stop_movement": + self.stop_mount() + elif command_type == "manual_movement": + self.manual_move(str(command.get("direction", ""))) + elif command_type == "increase_step_size": + self.change_step(2.0) + elif command_type == "reduce_step_size": + self.change_step(0.5) + elif command_type == "sync_location_time": + self.sync_location_time() + else: + logger.warning("Unknown mount-control command: %s", command_type) + return True + + def run(self) -> None: + _write_status( + "idle", + f"Mount-control process ready for {self.indi_host}:{self.indi_port}", + step_degrees=self.step_degrees, + ) + self.connect() + + running = True + while running: + try: + command = self.mount_queue.get(timeout=1.0) + running = self.handle_command(command) + except queue.Empty: + continue + except Exception as exc: + logger.exception("Mount-control command failed") + _write_status("error", str(exc)) + self._console("INDI mount\ncommand failed") + + self.disconnect() + + +def run( + mount_queue: Queue, + console_queue: Queue, + shared_state, + log_queue: Queue, + indi_host: str = "localhost", + indi_port: int = 7624, +) -> None: + """Process entry point used by ``main.py``.""" + MultiprocLogging.configurer(log_queue) + controller = MountControlIndi( + mount_queue, + console_queue, + shared_state, + indi_host=indi_host, + indi_port=indi_port, + ) + controller.run() diff --git a/python/PiFinder/ui/callbacks.py b/python/PiFinder/ui/callbacks.py index dcdb35fb9..c950a6bc9 100644 --- a/python/PiFinder/ui/callbacks.py +++ b/python/PiFinder/ui/callbacks.py @@ -183,6 +183,14 @@ def restart_pifinder(ui_module: UIModule) -> None: sys_utils.restart_pifinder() +def mount_control_toggle(ui_module: UIModule) -> None: + """Restart PiFinder after changing the optional INDI mount-control process.""" + enabled = ui_module.config_object.get_option("mount_control", False) + message = _("Mount Control\nOn") if enabled else _("Mount Control\nOff") + ui_module.message(message, 1) + restart_pifinder(ui_module) + + def restart_system(ui_module: UIModule) -> None: """ Restarts the system diff --git a/python/PiFinder/ui/menu_structure.py b/python/PiFinder/ui/menu_structure.py index 5a8b97f66..1325e3e43 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -1244,6 +1244,23 @@ def _(key: str) -> Any: "class": UIPolarAlign, "stateful": True, }, + { + "name": _("Mount Control"), + "class": UITextMenu, + "select": "single", + "config_option": "mount_control", + "post_callback": callbacks.mount_control_toggle, + "items": [ + { + "name": _("Off"), + "value": False, + }, + { + "name": _("On"), + "value": True, + }, + ], + }, { "name": _("Dev Tools"), "class": UITextMenu, diff --git a/python/PiFinder/ui/object_details.py b/python/PiFinder/ui/object_details.py index c142519ab..9a3b06ff8 100644 --- a/python/PiFinder/ui/object_details.py +++ b/python/PiFinder/ui/object_details.py @@ -26,6 +26,7 @@ ) from PiFinder import calc_utils import functools +import logging from PiFinder.db.observations_db import ObservationsDatabase from PiFinder.db.objects_db import ObjectsDatabase @@ -33,6 +34,8 @@ import time import pydeepskylog as pds +logger = logging.getLogger("UI.ObjectDetails") + # Read-only handle to the catalog DB, opened once and shared across detail # views. Used by _other_catalog_descriptions() to pull an object's listings in @@ -686,6 +689,68 @@ def mm_align(self, _marking_menu, _menu_item) -> bool: return True + def _mount_control_queue(self): + if not self.config_object.get_option("mount_control", False): + return None + return self.command_queues.get("mountcontrol") + + def _current_pointing_radec(self): + solution = self.shared_state.solution() + if not solution or not solution.has_pointing(): + return None + aligned = solution.pointing.aligned.estimate + if aligned is None: + return None + return aligned.RA, aligned.Dec + + def key_number(self, number): + """Handle Object Details numeric keys for optional INDI mount control.""" + mountcontrol_queue = self._mount_control_queue() + if mountcontrol_queue is None: + return + + if number == 0: + mountcontrol_queue.put({"type": "stop_movement"}) + self.message(_("Mount Stop"), 1) + elif number == 1: + mountcontrol_queue.put({"type": "init"}) + pointing = self._current_pointing_radec() + if pointing is not None: + mountcontrol_queue.put( + {"type": "sync", "ra": pointing[0], "dec": pointing[1]} + ) + self.message(_("Mount Init"), 1) + elif number == 2: + mountcontrol_queue.put({"type": "manual_movement", "direction": "south"}) + elif number == 3: + mountcontrol_queue.put({"type": "reduce_step_size"}) + elif number == 4: + mountcontrol_queue.put({"type": "manual_movement", "direction": "west"}) + elif number == 5: + mountcontrol_queue.put( + { + "type": "goto_target", + "ra": self.object.ra, + "dec": self.object.dec, + } + ) + self.message(_("Mount GoTo"), 1) + elif number == 6: + mountcontrol_queue.put({"type": "manual_movement", "direction": "east"}) + elif number == 7: + pointing = self._current_pointing_radec() + if pointing is None: + self.message(_("No solve"), 1) + return + mountcontrol_queue.put({"type": "sync", "ra": pointing[0], "dec": pointing[1]}) + self.message(_("Mount Sync"), 1) + elif number == 8: + mountcontrol_queue.put({"type": "manual_movement", "direction": "north"}) + elif number == 9: + mountcontrol_queue.put({"type": "increase_step_size"}) + else: + logger.warning("Unhandled mount-control number key: %s", number) + def key_down(self): self.maybe_add_to_recents() self.scroll_object(1) diff --git a/scripts/install_indi_mount.sh b/scripts/install_indi_mount.sh new file mode 100755 index 000000000..9d95cbe42 --- /dev/null +++ b/scripts/install_indi_mount.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +set -euo pipefail + +INDI_VERSION="${INDI_VERSION:-v2.1.6}" +INDI_3RDPARTY_VERSION="${INDI_3RDPARTY_VERSION:-v2.1.6.2}" +PYINDI_VERSION="${PYINDI_VERSION:-v2.1.2}" +FASTAPI_VERSION="${FASTAPI_VERSION:-0.103.2}" +STARLETTE_VERSION="${STARLETTE_VERSION:-0.27.0}" +UVICORN_VERSION="${UVICORN_VERSION:-0.23.2}" +ANYIO_VERSION="${ANYIO_VERSION:-3.7.1}" +JOBS="${JOBS:-2}" +BUILD_ROOT="${BUILD_ROOT:-$HOME}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DISABLE_CAMERA_DRIVER_OPTIONS=( + -DWITH_WEBCAM=OFF + -DWITH_SX=OFF + -DWITH_MI=OFF + -DWITH_FLI=OFF + -DWITH_SBIG=OFF + -DWITH_INOVAPLX=OFF + -DWITH_APOGEE=OFF + -DWITH_FFMV=OFF + -DWITH_QHY=OFF + -DWITH_GPHOTO=OFF + -DWITH_QSI=OFF + -DWITH_FISHCAMP=OFF + -DWITH_DSI=OFF + -DWITH_ASICAM=OFF + -DWITH_GIGE=OFF + -DWITH_NIGHTSCAPE=OFF + -DWITH_ATIK=OFF + -DWITH_TOUPCAM=OFF + -DWITH_ALTAIRCAM=OFF + -DWITH_BRESSERCAM=OFF + -DWITH_MALLINCAM=OFF + -DWITH_MEADECAM=OFF + -DWITH_NNCAM=OFF + -DWITH_OGMACAM=OFF + -DWITH_OPENOGMA=OFF + -DWITH_OMEGONPROCAM=OFF + -DWITH_STARSHOOTG=OFF + -DWITH_TSCAM=OFF + -DWITH_SVBONYCAM=OFF + -DWITH_PENTAX=OFF + -DWITH_ORION_SSG3=OFF + -DWITH_SVBONY=OFF + -DWITH_PLAYERONE=OFF + -DWITH_MGEN=OFF + -DWITH_ASTROASIS=OFF +) + +cmake_install_if_available() { + local target_list + target_list="$(mktemp)" + cmake --build . --target help >"${target_list}" + if grep -Eq '(^|[.][.][.] )install($|[[:space:]])' "${target_list}"; then + rm -f "${target_list}" + sudo cmake --build . --target install + else + rm -f "${target_list}" + echo "No CMake install target in $(pwd); skipping install." + fi +} + +echo "PiFinder INDI mount-control installer" +echo "INDI: ${INDI_VERSION}, INDI 3rd-party: ${INDI_3RDPARTY_VERSION}" +echo + +sudo apt update +sudo apt install -y \ + build-essential cmake git swig pkg-config meson ninja-build \ + cdbs dkms fxload libev-dev libgps-dev libgsl-dev libraw-dev \ + libusb-dev zlib1g-dev libftdi-dev libftdi1-dev libjpeg-dev \ + libkrb5-dev libnova-dev libtiff-dev libfftw3-dev librtlsdr-dev \ + libcfitsio-dev libgphoto2-dev libusb-1.0-0-dev libdc1394-dev \ + libboost-dev libboost-regex-dev libcurl4-gnutls-dev libtheora-dev \ + liblimesuite-dev libavcodec-dev libavdevice-dev libzmq3-dev \ + libudev-dev libdbus-1-dev libglib2.0-dev python3-pip \ + python3-setuptools python-dev-is-python3 chrony + +sudo systemctl stop pifinder || true + +PIP_BREAK_SYSTEM_PACKAGES=1 sudo python3 -m pip install --break-system-packages \ + jinja2 \ + "fastapi==${FASTAPI_VERSION}" \ + "starlette==${STARLETTE_VERSION}" \ + "uvicorn==${UVICORN_VERSION}" \ + "anyio==${ANYIO_VERSION}" + +cd "${BUILD_ROOT}" +if [ ! -d indi/.git ]; then + rm -rf indi + git clone --branch "${INDI_VERSION}" --depth 1 https://github.com/indilib/indi.git +fi + +mkdir -p indi/build +cd indi/build +cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr \ + "${DISABLE_CAMERA_DRIVER_OPTIONS[@]}" \ + .. +make -j"${JOBS}" +cmake_install_if_available + +PIP_BREAK_SYSTEM_PACKAGES=1 sudo python3 -m pip install --break-system-packages \ + "git+https://github.com/indilib/pyindi-client.git@${PYINDI_VERSION}#egg=pyindi-client" + +cd "${BUILD_ROOT}" +if [ ! -d indi-3rdparty/.git ]; then + rm -rf indi-3rdparty + git clone --branch "${INDI_3RDPARTY_VERSION}" --depth 1 https://github.com/indilib/indi-3rdparty.git +fi + +mkdir -p indi-3rdparty/build-libs +cd indi-3rdparty/build-libs +cmake -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release -DBUILD_LIBS=1 \ + "${DISABLE_CAMERA_DRIVER_OPTIONS[@]}" \ + .. +make -j"${JOBS}" +cmake_install_if_available + +mkdir -p ../build-drivers +cd ../build-drivers +cmake -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_SHARED_LINKER_FLAGS="-ludev" \ + "${DISABLE_CAMERA_DRIVER_OPTIONS[@]}" \ + .. +make -j"${JOBS}" +cmake_install_if_available + +PIP_BREAK_SYSTEM_PACKAGES=1 sudo python3 -m pip install --break-system-packages \ + "git+https://github.com/jscheidtmann/indiwebmanager.git@control_panel#egg=indiweb" + +CURRENT_USER="$(whoami)" +cat >/tmp/indiwebmanager.service </dev/null + echo "# Sync time from GPSD" | sudo tee -a /etc/chrony/chrony.conf >/dev/null + echo "refclock SHM 0 poll 3 refid gps1" | sudo tee -a /etc/chrony/chrony.conf >/dev/null +fi +sudo systemctl restart chrony +sudo systemctl start pifinder || true + +echo +echo "INDI mount-control install complete." +echo "Open INDI Web Manager at: http://pifinder.local:8624" +echo "Enable PiFinder mount control from Settings > Experimental > Mount Control." From 7ae84c6bade7f4adc2482d0c269850fcdc934922 Mon Sep 17 00:00:00 2001 From: hjoungjoo <149982795+hjoungjoo@users.noreply.github.com> Date: Sat, 27 Jun 2026 08:07:20 +0900 Subject: [PATCH 2/3] Handle INDI mount disconnect status --- python/PiFinder/mountcontrol_indi.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/python/PiFinder/mountcontrol_indi.py b/python/PiFinder/mountcontrol_indi.py index 513b56cf9..62bd5f08d 100644 --- a/python/PiFinder/mountcontrol_indi.py +++ b/python/PiFinder/mountcontrol_indi.py @@ -235,6 +235,8 @@ def serverConnected(self): def serverDisconnected(self, code): clientlogger.warning("Disconnected from INDI server: %s", code) + if self.mount_control is not None: + self.mount_control.mark_disconnected(f"INDI server disconnected: {code}") else: @@ -290,9 +292,17 @@ def _wait_for_device(self, timeout: float = 10.0) -> bool: return False def connect(self) -> bool: - if self.connected and self.device is not None: + if ( + self.connected + and self.device is not None + and self.client is not None + and self.client.isServerConnected() + ): return True + if self.connected: + self.mark_disconnected("INDI server connection is not active") + if PyIndi is None: _write_status("missing_pyindi", "PyIndi is not installed") self._console("INDI mount\nPyIndi missing") @@ -347,6 +357,11 @@ def connect(self) -> bool: self._console("INDI mount\nconnected") return True + def mark_disconnected(self, message: str) -> None: + self.connected = False + self.device = None + _write_status("disconnected", message) + def disconnect(self) -> None: if self.client is not None: try: From e416c6b6e845abceb65bafb8db8a7d819bc97336 Mon Sep 17 00:00:00 2001 From: hjoungjoo <149982795+hjoungjoo@users.noreply.github.com> Date: Sat, 27 Jun 2026 08:10:36 +0900 Subject: [PATCH 3/3] Document keyboard mappings for INDI mount control --- docs/mf_keyboard_mapping_en.md | 95 ++++++++++++++++++++++++++++++++++ docs/mf_keyboard_mapping_ko.md | 94 +++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 docs/mf_keyboard_mapping_en.md create mode 100644 docs/mf_keyboard_mapping_ko.md diff --git a/docs/mf_keyboard_mapping_en.md b/docs/mf_keyboard_mapping_en.md new file mode 100644 index 000000000..5aa9c4e11 --- /dev/null +++ b/docs/mf_keyboard_mapping_en.md @@ -0,0 +1,95 @@ +# MF_PiFinder Keyboard Mapping + +This document summarizes the USB/Bluetooth keyboard and GPIO keypad mappings in +the `mf_pifinder` branch. + +## USB/Bluetooth Keyboard + +| Key | PiFinder input | +| --- | --- | +| Arrow keys | `LEFT`, `UP`, `DOWN`, `RIGHT` | +| Enter / Keypad Enter | `SQUARE` | +| Esc | `LEFT` | +| Backspace | `MINUS` | +| `=` / Keypad `+` | `PLUS` | +| `-` / Keypad `-` | `MINUS` | +| Number `0-9` / Keypad numbers | Number `0-9` | +| Space | Space character | +| `a-z` | Lowercase text input | +| `Shift + a-z` | Uppercase text input | + +## Alt Combinations + +| Key | PiFinder input | +| --- | --- | +| `Alt + Arrow key` | `ALT_LEFT`, `ALT_UP`, `ALT_DOWN`, `ALT_RIGHT` | +| `Alt + =` / `Alt + Keypad +` | `ALT_PLUS` | +| `Alt + -` / `Alt + Keypad -` | `ALT_MINUS` | +| `Alt + 0` / `Alt + Keypad 0` | `ALT_0` | +| `Alt + Enter` / `Alt + Keypad Enter` | `ALT_SQUARE` | + +## Long Press + +Holding a key for at least 1 second sends a long-key input. + +| Key | PiFinder input | +| --- | --- | +| Hold `Left` | `LNG_LEFT` | +| Hold `Right` | `LNG_RIGHT` | +| Hold `Enter` / `Keypad Enter` | `LNG_SQUARE` | +| Hold `Up` | Repeated `UP` | +| Hold `Down` | Repeated `DOWN` | + +For compatibility, pressing `Shift` or `Ctrl` with `Left`, `Up`, `Down`, +`Right`, or `Enter` sends `LNG_LEFT`, `LNG_UP`, `LNG_DOWN`, `LNG_RIGHT`, or +`LNG_SQUARE`. + +## GPIO Keypad + +| Keypad key | PiFinder input | +| --- | --- | +| Number keys | Number `0-9` | +| `+` | `PLUS` | +| `-` | `MINUS` | +| Square/confirm key | `SQUARE` | +| Direction keys | `LEFT`, `UP`, `DOWN`, `RIGHT` | + +On the GPIO keypad, holding `SQUARE` while pressing a direction key, `+`, `-`, +or `0` sends the matching `ALT_*` input. + +## INDI Mount Control + +INDI mount control is optional. It is available only after installing INDI +support with `scripts/install_indi_mount.sh` and enabling this PiFinder setting: + +```text +Settings > Experimental > Mount Control > On +``` + +When Mount Control is enabled and the Object Details screen is open, number keys +send mount-control commands. USB/Bluetooth number keys, keypad number keys, and +GPIO number keys behave the same way. + +| Key | INDI mount action | +| --- | --- | +| `0` | Stop mount | +| `1` | Initialize INDI connection and sync if PiFinder has a solve | +| `2` | Move south by the current step size | +| `3` | Decrease step size | +| `4` | Move west by the current step size | +| `5` | GoTo the current Object Details target | +| `6` | Move east by the current step size | +| `7` | Sync mount to the current PiFinder solved position | +| `8` | Move north by the current step size | +| `9` | Increase step size | + +Manual movement is implemented as a small RA/Dec GoTo offset from the current +mount coordinates. The default step size is 1 degree; `3` halves it and `9` +doubles it. + +If the INDI server or mount connection has a problem, the normal PiFinder +features continue running. Mount connection status is written here: + +```text +~/PiFinder_data/mount_control_status.json +``` diff --git a/docs/mf_keyboard_mapping_ko.md b/docs/mf_keyboard_mapping_ko.md new file mode 100644 index 000000000..70194241e --- /dev/null +++ b/docs/mf_keyboard_mapping_ko.md @@ -0,0 +1,94 @@ +# MF_PiFinder 키보드 매핑 + +이 문서는 `mf_pifinder` 브랜치의 USB/Bluetooth 키보드와 GPIO 키패드 입력 +매핑을 간단히 정리한다. + +## USB/Bluetooth 키보드 + +| 키 | PiFinder 입력 | +| --- | --- | +| 방향키 | `LEFT`, `UP`, `DOWN`, `RIGHT` | +| Enter / Keypad Enter | `SQUARE` | +| Esc | `LEFT` | +| Backspace | `MINUS` | +| `=` / Keypad `+` | `PLUS` | +| `-` / Keypad `-` | `MINUS` | +| 숫자 `0-9` / Keypad 숫자 | 숫자 `0-9` | +| Space | 공백 문자 | +| `a-z` | 영문 소문자 | +| `Shift + a-z` | 영문 대문자 | + +## Alt 조합 + +| 키 | PiFinder 입력 | +| --- | --- | +| `Alt + 방향키` | `ALT_LEFT`, `ALT_UP`, `ALT_DOWN`, `ALT_RIGHT` | +| `Alt + =` / `Alt + Keypad +` | `ALT_PLUS` | +| `Alt + -` / `Alt + Keypad -` | `ALT_MINUS` | +| `Alt + 0` / `Alt + Keypad 0` | `ALT_0` | +| `Alt + Enter` / `Alt + Keypad Enter` | `ALT_SQUARE` | + +## 길게 누르기 + +1초 이상 누르면 long key로 처리된다. + +| 키 | PiFinder 입력 | +| --- | --- | +| 길게 `Left` | `LNG_LEFT` | +| 길게 `Right` | `LNG_RIGHT` | +| 길게 `Enter` / `Keypad Enter` | `LNG_SQUARE` | +| 길게 `Up` | `UP` 반복 | +| 길게 `Down` | `DOWN` 반복 | + +호환용으로 `Shift` 또는 `Ctrl`과 함께 `Left`, `Up`, `Down`, `Right`, +`Enter`를 누르면 각각 `LNG_LEFT`, `LNG_UP`, `LNG_DOWN`, `LNG_RIGHT`, +`LNG_SQUARE`로 처리된다. + +## GPIO 키패드 + +| 키패드 | PiFinder 입력 | +| --- | --- | +| 숫자 키 | 숫자 `0-9` | +| `+` | `PLUS` | +| `-` | `MINUS` | +| 사각/확인 키 | `SQUARE` | +| 방향키 | `LEFT`, `UP`, `DOWN`, `RIGHT` | + +GPIO 키패드는 `SQUARE`를 누른 상태에서 방향키, `+`, `-`, `0`을 누르면 +해당 `ALT_*` 입력으로 처리된다. + +## INDI 마운트 제어 + +INDI 마운트 제어는 선택 기능이다. `scripts/install_indi_mount.sh`로 INDI +지원을 설치하고 PiFinder UI에서 다음 설정을 켠 경우에만 동작한다. + +```text +Settings > Experimental > Mount Control > On +``` + +Mount Control이 켜져 있고 Object Details 화면을 보고 있을 때, 숫자 키는 +마운트 제어 명령으로 사용된다. USB/Bluetooth 키보드의 숫자 키와 keypad +숫자 키, GPIO 숫자 키가 같은 방식으로 동작한다. + +| 키 | INDI 마운트 동작 | +| --- | --- | +| `0` | 마운트 정지 | +| `1` | INDI 연결 초기화, PiFinder solve가 있으면 Sync | +| `2` | 현재 step 크기만큼 South 이동 | +| `3` | step 크기 줄이기 | +| `4` | 현재 step 크기만큼 West 이동 | +| `5` | 현재 Object Details 대상 GoTo | +| `6` | 현재 step 크기만큼 East 이동 | +| `7` | 현재 PiFinder solve 위치로 마운트 Sync | +| `8` | 현재 step 크기만큼 North 이동 | +| `9` | step 크기 키우기 | + +수동 이동은 현재 마운트 RA/Dec 좌표에서 작은 GoTo 오프셋을 보내는 방식이다. +기본 step은 1도이며 `3`은 절반으로 줄이고 `9`는 두 배로 키운다. + +INDI 서버나 마운트 연결에 문제가 있어도 PiFinder 기본 기능은 계속 동작한다. +마운트 연결 상태는 다음 파일에서 확인할 수 있다. + +```text +~/PiFinder_data/mount_control_status.json +```