package api import ( "fmt" "time" "github.com/antitbone/dwarfctl/internal/transport" pb "github.com/antitbone/dwarfctl/proto" "google.golang.org/protobuf/proto" ) // Telescope wraps the WebSocket transport with typed telescope commands. type Telescope struct { t *transport.Client } // New creates a Telescope API client. func New(clientID string) *Telescope { return &Telescope{t: transport.NewClient(clientID, 1)} } // SetDebug enables verbose WebSocket traffic logging. func (t *Telescope) SetDebug(on bool) { t.t.Debug = on } // Connect opens a WebSocket connection to the telescope. func (t *Telescope) Connect(ip string) error { return t.t.Connect(ip) } // Close disconnects from the telescope. func (t *Telescope) Close() error { return t.t.Close() } // Notifications returns a channel for receiving NOTIFY packets. func (t *Telescope) Notifications() chan *pb.WsPacket { return t.t.SubscribeNotifications() } // send marshals an inner proto message, sends it, and returns the raw response. func (t *Telescope) send(cmd uint32, msg proto.Message) (*pb.WsPacket, error) { data, err := proto.Marshal(msg) if err != nil { return nil, fmt.Errorf("marshal: %w", err) } return t.t.Send(ModuleIDForCmd(cmd), cmd, data, 10*time.Second) } // sendEmpty sends a command with no payload (empty data field). func (t *Telescope) sendEmpty(cmd uint32) (*pb.WsPacket, error) { return t.t.Send(ModuleIDForCmd(cmd), cmd, nil, 10*time.Second) } // sendNotify marshals and sends without waiting for a response. func (t *Telescope) sendNotify(cmd uint32, msg proto.Message) error { data, err := proto.Marshal(msg) if err != nil { return err } return t.t.SendNotify(ModuleIDForCmd(cmd), cmd, data) } // decodeResponse parses the WsPacket data field into the given message type. func decodeResponse(pkt *pb.WsPacket, msg proto.Message) error { if len(pkt.GetData()) == 0 { return nil } return proto.Unmarshal(pkt.GetData(), msg) } // --- Camera commands --- // Camera identifies which camera to address. type Camera int const ( CameraTele Camera = 0 CameraWide Camera = 1 ) func cameraCmd(tele, wide uint32, cam Camera) uint32 { if cam == CameraWide { return wide } return tele } // OpenCamera opens the specified camera. // Some firmwares don't send a type=3 reply; fire-and-forget is safe. func (t *Telescope) OpenCamera(cam Camera) error { return t.sendNotify(cameraCmd(CmdTeleOpenCamera, CmdWideOpenCamera, cam), &pb.ReqMotorServiceJoystickStop{}) } // CloseCamera closes the specified camera. func (t *Telescope) CloseCamera(cam Camera) error { return t.sendNotify(cameraCmd(CmdTeleCloseCamera, CmdWideCloseCamera, cam), &pb.ReqMotorServiceJoystickStop{}) } // TakePhoto captures a single photograph. func (t *Telescope) TakePhoto(cam Camera) error { _, err := t.sendEmpty(cameraCmd(CmdTelePhotograph, CmdWidePhotograph, cam)) return err } // TakeRawPhoto captures a RAW photo. func (t *Telescope) TakeRawPhoto(cam Camera) error { _, err := t.sendEmpty(cameraCmd(CmdTelePhotoRaw, CmdWidePhotoRaw, cam)) return err } // StartBurst starts burst capture. func (t *Telescope) StartBurst(cam Camera) error { _, err := t.sendEmpty(cameraCmd(CmdTeleBurst, CmdWideBurst, cam)) return err } // StopBurst stops burst capture. func (t *Telescope) StopBurst(cam Camera) error { _, err := t.sendEmpty(cameraCmd(CmdTeleStopBurst, CmdWideStopBurst, cam)) return err } // StartRecord starts video recording. func (t *Telescope) StartRecord(cam Camera) error { _, err := t.sendEmpty(cameraCmd(CmdTeleStartRecord, CmdWideStartRecord, cam)) return err } // StopRecord stops video recording. func (t *Telescope) StopRecord(cam Camera) error { _, err := t.sendEmpty(cameraCmd(CmdTeleStopRecord, CmdWideStopRecord, cam)) return err } // SetExposure sets the exposure index on the camera. func (t *Telescope) SetExposure(cam Camera, index int32) error { _, err := t.send(cameraCmd(CmdTeleSetExp, CmdWideSetExp, cam), &pb.ReqSetExp{Index: index}) return err } // SetGain sets the gain index on the camera. func (t *Telescope) SetGain(cam Camera, index int32) error { _, err := t.send(cameraCmd(CmdTeleSetGain, CmdWideSetGain, cam), &pb.ReqSetGain{Index: index}) return err } // GetAllParams retrieves all camera parameters. // NOTE: this command may not receive a type=3 reply on some firmwares. // The params are also embedded in the GetDeviceState response. func (t *Telescope) GetAllParams(cam Camera) (*pb.ResGetAllParams, error) { pkt, err := t.sendEmpty(cameraCmd(CmdTeleGetAllParams, CmdWideGetAllParams, cam)) if err != nil { return nil, err } resp := &pb.ResGetAllParams{} return resp, decodeResponse(pkt, resp) } // --- Motor commands --- // SlewJoystick sends a joystick vector for manual slewing (fire-and-forget). func (t *Telescope) SlewJoystick(angle, length float64) error { return t.sendNotify(CmdMotorJoystick, &pb.ReqMotorServiceJoystick{ VectorAngle: angle, VectorLength: length, }) } // SlewJoystickFixedAngle sends a fixed-angle joystick vector. func (t *Telescope) SlewJoystickFixedAngle(angle, length float64) error { return t.sendNotify(CmdMotorJoystickFixedAngle, &pb.ReqMotorServiceJoystickFixedAngle{ VectorAngle: angle, VectorLength: length, }) } // MotorStop stops a motor by ID (0=RA/az, 1=DEC/alt). Fire-and-forget — // the telescope does not ack motor commands with a same-cmd response. func (t *Telescope) MotorStop(motorID int32) error { return t.sendNotify(CmdMotorStop, &pb.ReqMotorStop{Id: motorID}) } // MotorJoystickStop stops joystick movement. Fire-and-forget. func (t *Telescope) MotorJoystickStop() error { return t.sendNotify(CmdMotorJoystickStop, &pb.ReqMotorServiceJoystickStop{}) } // MotorRunTo moves a motor to a target position at a given speed. // Fire-and-forget — verify via GetDeviceState after sending. func (t *Telescope) MotorRunTo(id int32, endPos, speed float64) error { return t.sendNotify(CmdMotorRun, &pb.ReqMotorRunTo{ Id: id, EndPosition: endPos, Speed: speed, }) } // MotorGetPosition queries a motor's current position (cmd 14001). // Returns code=-14520 (NEED_RESET) if motors haven't been homed. // Note: this command ID is inferred, not in the APK's WsCmd enum, but // confirmed working via live testing. func (t *Telescope) MotorGetPosition(id int32) (*pb.ResMotorPosition, error) { pkt, err := t.send(CmdMotorGetPosition, &pb.ReqMotorGetPosition{Id: id}) if err != nil { return nil, err } resp := &pb.ResMotorPosition{} return resp, decodeResponse(pkt, resp) } // MotorReset resets a motor (finds home via limit switch). // cmd 14003 is inferred. WARNING: causes telescope reboot — but may be // the only way to unblock an axis stuck at a mechanical limit. func (t *Telescope) MotorReset(id int32, direction bool) error { return t.sendNotify(CmdMotorReset, &pb.ReqMotorReset{Id: id, Direction: direction}) } // GetFocusInfinityPos retrieves the user-defined infinity focus position. func (t *Telescope) GetFocusInfinityPos() (int32, error) { pkt, err := t.sendEmpty(CmdFocusGetUserInfinity) if err != nil { return 0, err } resp := &pb.ReqSetUserInfinityPos{} if err := decodeResponse(pkt, resp); err != nil { return 0, err } return resp.GetPos(), nil } // SetFocusInfinityPos saves the current focus position as user infinity. func (t *Telescope) SetFocusInfinityPos(pos int32) error { _, err := t.send(CmdFocusSetUserInfinity, &pb.ReqSetUserInfinityPos{Pos: pos}) return err } // --- Astro commands --- // StartCalibration begins star calibration. func (t *Telescope) StartCalibration() error { _, err := t.sendEmpty(CmdAstroStartCalibration) return err } // StopCalibration cancels calibration. func (t *Telescope) StopCalibration() error { _, err := t.sendEmpty(CmdAstroStopCalibration) return err } // GotoDSO slews to a deep-sky object by RA/Dec coordinates. func (t *Telescope) GotoDSO(ra, dec float64, name string) error { _, err := t.send(CmdAstroStartGotoDSO, &pb.ReqGotoDSO{Ra: ra, Dec: dec, TargetName: name}) return err } // GotoSolar slews to a solar system object by index. func (t *Telescope) GotoSolar(index int32) error { _, err := t.send(CmdAstroStartGotoSolar, &pb.ReqGotoSolarSystem{Index: index}) return err } // StopGoto aborts a GoTo operation. func (t *Telescope) StopGoto() error { _, err := t.sendEmpty(CmdAstroStopGoto) return err } // StartLiveStacking begins live-stacking capture. func (t *Telescope) StartLiveStacking() error { _, err := t.sendEmpty(CmdAstroStartLiveStacking) return err } // StopLiveStacking stops live-stacking. func (t *Telescope) StopLiveStacking() error { _, err := t.sendEmpty(CmdAstroStopLiveStacking) return err } // StartEqSolving begins equatorial solving (polar alignment). func (t *Telescope) StartEqSolving() error { _, err := t.sendEmpty(CmdAstroStartEqSolving) return err } // StopEqSolving stops equatorial solving. func (t *Telescope) StopEqSolving() error { _, err := t.sendEmpty(CmdAstroStopEqSolving) return err } // --- Focus commands --- // AutoFocus triggers autofocus. func (t *Telescope) AutoFocus() error { _, err := t.sendEmpty(CmdFocusAutoFocus) return err } // ManualSingleStepFocus moves the focuser a single step (direction 0=in, 1=out). func (t *Telescope) ManualSingleStepFocus(direction uint32) error { _, err := t.send(CmdFocusManualSingle, &pb.ReqManualSingleStepFocus{Direction: direction}) return err } // StartAstroAutoFocus starts astro autofocus. func (t *Telescope) StartAstroAutoFocus() error { _, err := t.sendEmpty(CmdFocusStartAstroAF) return err } // StopAstroAutoFocus stops astro autofocus. func (t *Telescope) StopAstroAutoFocus() error { _, err := t.sendEmpty(CmdFocusStopAstroAF) return err } // --- Track commands --- // StartTracking starts tracking an object at the given coordinates. func (t *Telescope) StartTracking(x, y, w, h, camID int32) error { _, err := t.send(CmdTrackStart, &pb.ReqStartTrack{ X: x, Y: y, W: w, H: h, CamId: camID, }) return err } // StopTracking stops tracking. func (t *Telescope) StopTracking() error { _, err := t.sendEmpty(CmdTrackStop) return err } // --- System commands --- // SetTime syncs the telescope clock. func (t *Telescope) SetTime(unixSec uint64, tzOffset float64) error { _, err := t.send(CmdSystemSetTime, &pb.ReqSetTime{ Timestamp: unixSec, TimezoneOffset: tzOffset, }) return err } // SetLocation sets the observing location. func (t *Telescope) SetLocation(lat, lng, alt float64) error { _, err := t.send(CmdSystemSetLocation, &pb.ReqSetLocation{ Latitude: lat, Longitude: lng, Altitude: alt, Enable: true, }) return err } // --- Power / RGB commands --- // RGBOn turns on the RGB ring light. Fire-and-forget — no type=3 reply. // Verify via GetDeviceState().DeviceStateInfo.RgbState. func (t *Telescope) RGBOn() error { return t.sendNotify(CmdRGBOn, &pb.ReqMotorServiceJoystickStop{}) } // RGBOff turns off the RGB ring light. Fire-and-forget. func (t *Telescope) RGBOff() error { return t.sendNotify(CmdRGBOff, &pb.ReqMotorServiceJoystickStop{}) } // PowerDown powers off the telescope. Fire-and-forget. func (t *Telescope) PowerDown() error { return t.sendNotify(CmdPowerDown, &pb.ReqMotorServiceJoystickStop{}) } // Reboot reboots the telescope. Fire-and-forget. func (t *Telescope) Reboot() error { return t.sendNotify(CmdReboot, &pb.ReqMotorServiceJoystickStop{}) } // --- Task Center --- // GetDeviceState queries the current device state. func (t *Telescope) GetDeviceState() (*pb.ResGetDeviceStateInfo, error) { pkt, err := t.sendEmpty(CmdTaskGetDeviceState) if err != nil { return nil, err } resp := &pb.ResGetDeviceStateInfo{} return resp, decodeResponse(pkt, resp) } // SwitchShootingMode switches the active shooting mode. func (t *Telescope) SwitchShootingMode(modeID int32) error { _, err := t.send(CmdTaskSwitchMode, &pb.ReqSwitchShootingMode{Mode: modeID}) return err } // --- Panorama commands --- // StartPanoramaGrid starts a panorama grid scan (triggers motor home/reset). func (t *Telescope) StartPanoramaGrid() error { return t.sendNotify(CmdPanoStartGrid, &pb.ReqStartPanoramaByGrid{}) } // StopPanorama stops the current panorama operation. func (t *Telescope) StopPanorama() error { return t.sendNotify(CmdPanoStop, &pb.ReqStopPanorama{}) }