diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 493258c8a..0dd711222 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -27,7 +27,6 @@ dependencies { implementation(project(":nebulosa-wcs")) implementation(project(":nebulosa-log")) implementation(libs.csv) - implementation(libs.jackson) implementation(libs.okhttp) implementation(libs.oshi) implementation(libs.eventbus) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt new file mode 100644 index 000000000..9d79844e8 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt @@ -0,0 +1,30 @@ +package nebulosa.api.alignment.polar + +import nebulosa.api.alignment.polar.darv.DARVStart +import nebulosa.api.beans.annotations.EntityBy +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.guide.GuideOutput +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("polar-alignment") +class PolarAlignmentController( + private val polarAlignmentService: PolarAlignmentService, +) { + + @PutMapping("darv/{camera}/{guideOutput}/start") + fun darvStart( + @EntityBy camera: Camera, @EntityBy guideOutput: GuideOutput, + @RequestBody body: DARVStart, + ) { + polarAlignmentService.darvStart(camera, guideOutput, body) + } + + @PutMapping("darv/{camera}/{guideOutput}/stop") + fun darvStop(@EntityBy camera: Camera, @EntityBy guideOutput: GuideOutput) { + polarAlignmentService.darvStop(camera, guideOutput) + } +} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt new file mode 100644 index 000000000..7265a3d76 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt @@ -0,0 +1,23 @@ +package nebulosa.api.alignment.polar + +import nebulosa.api.alignment.polar.darv.DARVPolarAlignmentExecutor +import nebulosa.api.alignment.polar.darv.DARVStart +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.guide.GuideOutput +import org.springframework.stereotype.Service + +@Service +class PolarAlignmentService( + private val darvPolarAlignmentExecutor: DARVPolarAlignmentExecutor, +) { + + fun darvStart(camera: Camera, guideOutput: GuideOutput, darvStart: DARVStart) { + check(camera.connected) { "camera not connected" } + check(guideOutput.connected) { "guide output not connected" } + darvPolarAlignmentExecutor.execute(darvStart.copy(camera = camera, guideOutput = guideOutput)) + } + + fun darvStop(camera: Camera, guideOutput: GuideOutput) { + darvPolarAlignmentExecutor.stop(camera, guideOutput) + } +} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentEvent.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentEvent.kt new file mode 100644 index 000000000..6c5e48f42 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentEvent.kt @@ -0,0 +1,11 @@ +package nebulosa.api.alignment.polar.darv + +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.guide.GuideOutput + +sealed interface DARVPolarAlignmentEvent { + + val camera: Camera + + val guideOutput: GuideOutput +} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentExecutor.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentExecutor.kt new file mode 100644 index 000000000..62433be0d --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentExecutor.kt @@ -0,0 +1,172 @@ +package nebulosa.api.alignment.polar.darv + +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.cameras.CameraCaptureEvent +import nebulosa.api.cameras.CameraStartCaptureRequest +import nebulosa.api.guiding.* +import nebulosa.api.sequencer.* +import nebulosa.api.sequencer.tasklets.delay.DelayElapsed +import nebulosa.api.services.MessageService +import nebulosa.common.concurrency.Incrementer +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.guide.GuideOutput +import nebulosa.log.loggerFor +import org.springframework.batch.core.JobExecution +import org.springframework.batch.core.JobExecutionListener +import org.springframework.batch.core.JobParameters +import org.springframework.batch.core.configuration.JobRegistry +import org.springframework.batch.core.configuration.support.ReferenceJobFactory +import org.springframework.batch.core.job.builder.JobBuilder +import org.springframework.batch.core.launch.JobLauncher +import org.springframework.batch.core.launch.JobOperator +import org.springframework.batch.core.repository.JobRepository +import org.springframework.core.task.SimpleAsyncTaskExecutor +import org.springframework.stereotype.Component +import java.nio.file.Path +import java.util.* +import kotlin.time.Duration.Companion.seconds + +/** + * @see Reference + */ +@Component +class DARVPolarAlignmentExecutor( + private val jobRepository: JobRepository, + private val jobOperator: JobOperator, + private val jobLauncher: JobLauncher, + private val jobRegistry: JobRegistry, + private val messageService: MessageService, + private val jobIncrementer: Incrementer, + private val capturesPath: Path, + private val sequenceFlowFactory: SequenceFlowFactory, + private val sequenceTaskletFactory: SequenceTaskletFactory, + private val simpleAsyncTaskExecutor: SimpleAsyncTaskExecutor, +) : SequenceJobExecutor, Consumer, JobExecutionListener { + + private val runningSequenceJobs = LinkedList() + + @Synchronized + override fun execute(request: DARVStart): DARVSequenceJob { + val camera = requireNotNull(request.camera) + val guideOutput = requireNotNull(request.guideOutput) + + if (isRunning(camera, guideOutput)) { + throw IllegalStateException("DARV Polar Alignment job is already running") + } + + LOG.info("starting DARV polar alignment. data={}", request) + + val cameraRequest = CameraStartCaptureRequest( + camera = camera, + exposureInMicroseconds = (request.exposureInSeconds + request.initialPauseInSeconds).seconds.inWholeMicroseconds, + savePath = Path.of("$capturesPath", "${camera.name}-DARV.fits") + ) + + val cameraExposureTasklet = sequenceTaskletFactory.cameraExposure(cameraRequest) + cameraExposureTasklet.subscribe(this) + val cameraExposureFlow = sequenceFlowFactory.cameraExposure(cameraExposureTasklet) + + val guidePulseDuration = (request.exposureInSeconds / 2.0).seconds.inWholeMilliseconds + val initialPauseDelayTasklet = sequenceTaskletFactory.delay(request.initialPauseInSeconds.seconds) + initialPauseDelayTasklet.subscribe(this) + + val direction = if (request.reversed) request.direction.reversed else request.direction + + val forwardGuidePulseRequest = GuidePulseRequest(guideOutput, direction, guidePulseDuration) + val forwardGuidePulseTasklet = sequenceTaskletFactory.guidePulse(forwardGuidePulseRequest) + forwardGuidePulseTasklet.subscribe(this) + + val backwardGuidePulseRequest = GuidePulseRequest(guideOutput, direction.reversed, guidePulseDuration) + val backwardGuidePulseTasklet = sequenceTaskletFactory.guidePulse(backwardGuidePulseRequest) + backwardGuidePulseTasklet.subscribe(this) + + val guidePulseFlow = sequenceFlowFactory.guidePulse(initialPauseDelayTasklet, forwardGuidePulseTasklet, backwardGuidePulseTasklet) + + val darvJob = JobBuilder("DARVPolarAlignment.Job.${jobIncrementer.increment()}", jobRepository) + .start(cameraExposureFlow) + .split(simpleAsyncTaskExecutor) + .add(guidePulseFlow) + .end() + .listener(this) + .listener(cameraExposureTasklet) + .build() + + return jobLauncher + .run(darvJob, JobParameters()) + .let { DARVSequenceJob(camera, guideOutput, request, darvJob, it) } + .also(runningSequenceJobs::add) + .also { jobRegistry.register(ReferenceJobFactory(darvJob)) } + } + + @Synchronized + fun stop(camera: Camera, guideOutput: GuideOutput) { + val jobExecution = jobExecutionFor(camera, guideOutput) ?: return + jobOperator.stop(jobExecution.id) + } + + @Suppress("NOTHING_TO_INLINE") + private inline fun jobExecutionFor(camera: Camera, guideOutput: GuideOutput): JobExecution? { + return sequenceJobFor(camera, guideOutput)?.jobExecution + } + + fun isRunning(camera: Camera, guideOutput: GuideOutput): Boolean { + return sequenceJobFor(camera, guideOutput)?.jobExecution?.isRunning ?: false + } + + override fun accept(event: SequenceTaskletEvent) { + if (event !is SequenceJobEvent) { + LOG.warn("unaccepted sequence task event: {}", event) + return + } + + val (camera, guideOutput, data) = sequenceJobWithId(event.jobExecution.jobId) ?: return + + val messageEvent = when (event) { + // Initial pulse event. + is DelayElapsed -> { + DARVPolarAlignmentInitialPauseElapsed(camera, guideOutput, event) + } + // Forward & backward guide pulse event. + is GuidePulseEvent -> { + val direction = event.tasklet.request.direction + val duration = event.tasklet.request.durationInMilliseconds + val forward = (direction == data.direction) != data.reversed + + when (event) { + is GuidePulseStarted -> { + DARVPolarAlignmentGuidePulseElapsed(camera, guideOutput, forward, direction, duration, 0.0) + } + is GuidePulseElapsed -> { + DARVPolarAlignmentGuidePulseElapsed(camera, guideOutput, forward, direction, event.remainingTime, event.progress) + } + is GuidePulseFinished -> { + DARVPolarAlignmentGuidePulseElapsed(camera, guideOutput, forward, direction, 0L, 1.0) + } + } + } + is CameraCaptureEvent -> event + else -> return + } + + messageService.sendMessage(messageEvent) + } + + override fun beforeJob(jobExecution: JobExecution) { + val (camera, guideOutput) = sequenceJobWithId(jobExecution.jobId) ?: return + messageService.sendMessage(DARVPolarAlignmentStarted(camera, guideOutput)) + } + + override fun afterJob(jobExecution: JobExecution) { + val (camera, guideOutput) = sequenceJobWithId(jobExecution.jobId) ?: return + messageService.sendMessage(DARVPolarAlignmentFinished(camera, guideOutput)) + } + + override fun iterator(): Iterator { + return runningSequenceJobs.iterator() + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentFinished.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentFinished.kt new file mode 100644 index 000000000..5e9f2d353 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentFinished.kt @@ -0,0 +1,14 @@ +package nebulosa.api.alignment.polar.darv + +import com.fasterxml.jackson.annotation.JsonIgnore +import nebulosa.api.services.MessageEvent +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.guide.GuideOutput + +data class DARVPolarAlignmentFinished( + override val camera: Camera, + override val guideOutput: GuideOutput, +) : MessageEvent, DARVPolarAlignmentEvent { + + @JsonIgnore override val eventName = "DARV_POLAR_ALIGNMENT_FINISHED" +} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentGuidePulseElapsed.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentGuidePulseElapsed.kt new file mode 100644 index 000000000..6f13686a3 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentGuidePulseElapsed.kt @@ -0,0 +1,19 @@ +package nebulosa.api.alignment.polar.darv + +import com.fasterxml.jackson.annotation.JsonIgnore +import nebulosa.api.services.MessageEvent +import nebulosa.guiding.GuideDirection +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.guide.GuideOutput + +data class DARVPolarAlignmentGuidePulseElapsed( + override val camera: Camera, + override val guideOutput: GuideOutput, + val forward: Boolean, + val direction: GuideDirection, + val remainingTime: Long, + val progress: Double, +) : MessageEvent, DARVPolarAlignmentEvent { + + @JsonIgnore override val eventName = "DARV_POLAR_ALIGNMENT_GUIDE_PULSE_ELAPSED" +} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentInitialPauseElapsed.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentInitialPauseElapsed.kt new file mode 100644 index 000000000..c8aff9dea --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentInitialPauseElapsed.kt @@ -0,0 +1,23 @@ +package nebulosa.api.alignment.polar.darv + +import com.fasterxml.jackson.annotation.JsonIgnore +import nebulosa.api.sequencer.DelayEvent +import nebulosa.api.services.MessageEvent +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.guide.GuideOutput + +data class DARVPolarAlignmentInitialPauseElapsed( + override val camera: Camera, + override val guideOutput: GuideOutput, + val pauseTime: Long, + val remainingTime: Long, + val progress: Double, +) : MessageEvent, DARVPolarAlignmentEvent { + + constructor(camera: Camera, guideOutput: GuideOutput, delay: DelayEvent) : this( + camera, guideOutput, delay.waitTime.inWholeMicroseconds, + delay.remainingTime.inWholeMicroseconds, delay.progress + ) + + @JsonIgnore override val eventName = "DARV_POLAR_ALIGNMENT_INITIAL_PAUSE_ELAPSED" +} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentStarted.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentStarted.kt new file mode 100644 index 000000000..25d53a927 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVPolarAlignmentStarted.kt @@ -0,0 +1,14 @@ +package nebulosa.api.alignment.polar.darv + +import com.fasterxml.jackson.annotation.JsonIgnore +import nebulosa.api.services.MessageEvent +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.guide.GuideOutput + +data class DARVPolarAlignmentStarted( + override val camera: Camera, + override val guideOutput: GuideOutput, +) : MessageEvent, DARVPolarAlignmentEvent { + + @JsonIgnore override val eventName = "DARV_POLAR_ALIGNMENT_STARTED" +} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVSequenceJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVSequenceJob.kt new file mode 100644 index 000000000..6ea79d91a --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVSequenceJob.kt @@ -0,0 +1,18 @@ +package nebulosa.api.alignment.polar.darv + +import nebulosa.api.sequencer.SequenceJob +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.guide.GuideOutput +import org.springframework.batch.core.Job +import org.springframework.batch.core.JobExecution + +data class DARVSequenceJob( + val camera: Camera, + val guideOutput: GuideOutput, + val data: DARVStart, + override val job: Job, + override val jobExecution: JobExecution, +) : SequenceJob { + + override val devices = listOf(camera, guideOutput) +} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStart.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStart.kt new file mode 100644 index 000000000..6d7bbc23d --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStart.kt @@ -0,0 +1,16 @@ +package nebulosa.api.alignment.polar.darv + +import com.fasterxml.jackson.annotation.JsonIgnore +import nebulosa.guiding.GuideDirection +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.guide.GuideOutput +import org.hibernate.validator.constraints.Range + +data class DARVStart( + @JsonIgnore val camera: Camera? = null, + @JsonIgnore val guideOutput: GuideOutput? = null, + @Range(min = 1, max = 600) val exposureInSeconds: Long = 0L, + @Range(min = 1, max = 60) val initialPauseInSeconds: Long = 0L, + val direction: GuideDirection = GuideDirection.NORTH, + val reversed: Boolean = false, +) diff --git a/api/src/main/kotlin/nebulosa/api/beans/EntityByMethodArgumentResolver.kt b/api/src/main/kotlin/nebulosa/api/beans/EntityByMethodArgumentResolver.kt index 10baa2c44..2bd183e30 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/EntityByMethodArgumentResolver.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/EntityByMethodArgumentResolver.kt @@ -13,11 +13,13 @@ import nebulosa.indi.device.guide.GuideOutput import nebulosa.indi.device.mount.Mount import org.springframework.core.MethodParameter import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import org.springframework.web.bind.support.WebDataBinderFactory import org.springframework.web.context.request.NativeWebRequest import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.ModelAndViewContainer +import org.springframework.web.server.ResponseStatusException import org.springframework.web.servlet.HandlerMapping @Component @@ -39,12 +41,27 @@ class EntityByMethodArgumentResolver( webRequest: NativeWebRequest, binderFactory: WebDataBinderFactory? ): Any? { + val entityBy = parameter.getParameterAnnotation(EntityBy::class.java)!! + val parameterType = parameter.parameterType val parameterName = parameter.parameterName ?: "id" val parameterValue = webRequest.pathVariables()[parameterName] ?: webRequest.getParameter(parameterName) - ?: return null + ?: throw throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Parameter $parameterName is not mapped") - return when (parameter.parameterType) { + val entity = entityByParameterValue(parameterType, parameterValue) + + if (entityBy.required && entity == null) { + val message = "Cannot found a ${parameterType.simpleName} entity with name [$parameterValue]" + throw throw ResponseStatusException(HttpStatus.NOT_FOUND, message) + } + + return entity + } + + private fun entityByParameterValue(parameterType: Class<*>, parameterValue: String?): Any? { + if (parameterValue.isNullOrBlank()) return null + + return when (parameterType) { LocationEntity::class.java -> locationRepository.findByIdOrNull(parameterValue.toLong()) StarEntity::class.java -> starRepository.findByIdOrNull(parameterValue.toLong()) DeepSkyObjectEntity::class.java -> deepSkyObjectRepository.findByIdOrNull(parameterValue.toLong()) diff --git a/api/src/main/kotlin/nebulosa/api/beans/annotations/EntityBy.kt b/api/src/main/kotlin/nebulosa/api/beans/annotations/EntityBy.kt index 11a05146c..ed3846b4a 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/annotations/EntityBy.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/annotations/EntityBy.kt @@ -2,4 +2,4 @@ package nebulosa.api.beans.annotations @Retention @Target(AnnotationTarget.VALUE_PARAMETER) -annotation class EntityBy +annotation class EntityBy(val required: Boolean = true) diff --git a/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt b/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt index afda0f311..f639f230b 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt @@ -1,15 +1,16 @@ package nebulosa.api.beans.configurations import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.kotlinModule import nebulosa.api.beans.DateAndTimeMethodArgumentResolver import nebulosa.api.beans.EntityByMethodArgumentResolver import nebulosa.common.concurrency.DaemonThreadFactory import nebulosa.common.concurrency.Incrementer +import nebulosa.guiding.Guider +import nebulosa.guiding.phd2.PHD2Guider import nebulosa.hips2fits.Hips2FitsService import nebulosa.horizons.HorizonsService -import nebulosa.json.FromJson -import nebulosa.json.SimpleJsonModule -import nebulosa.json.ToJson +import nebulosa.json.* import nebulosa.json.converters.PathConverter import nebulosa.phd2.client.PHD2Client import nebulosa.sbd.SmallBodyDatabaseService @@ -57,10 +58,10 @@ class BeanConfiguration { fun cachePath(appPath: Path): Path = Path.of("$appPath", "cache").createDirectories() @Bean - fun simpleJsonModule( + fun kotlinModule( serializers: List>, deserializers: List>, - ) = SimpleJsonModule() + ) = kotlinModule() .apply { serializers.forEach { addSerializer(it) } } .apply { deserializers.forEach { addDeserializer(it) } } .addConverter(PathConverter) @@ -128,11 +129,23 @@ class BeanConfiguration { } @Bean - fun executionIncrementer() = Incrementer() + fun flowIncrementer() = Incrementer() + + @Bean + fun stepIncrementer() = Incrementer() + + @Bean + fun jobIncrementer() = Incrementer() @Bean fun phd2Client() = PHD2Client() + @Bean + fun phd2Guider(phd2Client: PHD2Client): Guider = PHD2Guider(phd2Client) + + @Bean + fun simpleAsyncTaskExecutor() = SimpleAsyncTaskExecutor(DaemonThreadFactory) + @Bean fun webMvcConfigurer( entityByMethodArgumentResolver: EntityByMethodArgumentResolver, diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt index cfd443696..6de16c81f 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt @@ -1,9 +1,10 @@ package nebulosa.api.cameras +import nebulosa.api.sequencer.SequenceTaskletEvent import nebulosa.api.services.MessageEvent import nebulosa.indi.device.camera.Camera -sealed interface CameraCaptureEvent : MessageEvent { +sealed interface CameraCaptureEvent : MessageEvent, SequenceTaskletEvent { val camera: Camera diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt index eb5a55ea4..e4828afda 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt @@ -1,122 +1,72 @@ package nebulosa.api.cameras import io.reactivex.rxjava3.functions.Consumer -import nebulosa.api.sequencer.SequenceJob import nebulosa.api.sequencer.SequenceJobExecutor -import nebulosa.api.sequencer.tasklets.delay.DelayTasklet +import nebulosa.api.sequencer.SequenceJobFactory import nebulosa.api.services.MessageService -import nebulosa.common.concurrency.Incrementer import nebulosa.indi.device.camera.Camera import nebulosa.log.loggerFor import org.springframework.batch.core.JobExecution import org.springframework.batch.core.JobParameters import org.springframework.batch.core.configuration.JobRegistry import org.springframework.batch.core.configuration.support.ReferenceJobFactory -import org.springframework.batch.core.job.builder.JobBuilder import org.springframework.batch.core.launch.JobLauncher import org.springframework.batch.core.launch.JobOperator -import org.springframework.batch.core.repository.JobRepository -import org.springframework.batch.core.step.builder.StepBuilder -import org.springframework.batch.core.step.tasklet.Tasklet import org.springframework.stereotype.Component -import org.springframework.transaction.PlatformTransactionManager import java.util.* -import kotlin.time.Duration.Companion.seconds @Component class CameraCaptureExecutor( - private val jobRepository: JobRepository, private val jobOperator: JobOperator, private val asyncJobLauncher: JobLauncher, - private val platformTransactionManager: PlatformTransactionManager, private val jobRegistry: JobRegistry, private val messageService: MessageService, - private val executionIncrementer: Incrementer, -) : SequenceJobExecutor, Consumer { + private val sequenceJobFactory: SequenceJobFactory, +) : SequenceJobExecutor, Consumer { - private val runningSequenceJobs = LinkedList() + private val runningSequenceJobs = LinkedList() @Synchronized - override fun execute(data: CameraStartCapture): SequenceJob { - val camera = requireNotNull(data.camera) + override fun execute(request: CameraStartCaptureRequest): CameraSequenceJob { + val camera = requireNotNull(request.camera) - if (isCapturing(camera)) { - throw IllegalStateException("A Camera Exposure job is already running. camera=${camera.name}") - } - - LOG.info("starting camera capture. data={}", data) + check(!isCapturing(camera)) { "job is already running for camera: [${camera.name}]" } + check(camera.connected) { "camera is not connected" } - val cameraCaptureJob = if (data.isLoop) { - val cameraExposureTasklet = CameraLoopExposureTasklet(data) - cameraExposureTasklet.subscribe(this) + LOG.info("starting camera capture. data={}", request) - JobBuilder("CameraCapture.Job.${executionIncrementer.increment()}", jobRepository) - .start(cameraExposureStep(cameraExposureTasklet)) - .listener(cameraExposureTasklet) - .build() + val cameraCaptureJob = if (request.isLoop) { + sequenceJobFactory.cameraLoopCapture(request, this) } else { - val cameraExposureTasklet = CameraExposureTasklet(data) - cameraExposureTasklet.subscribe(this) - - val jobBuilder = JobBuilder("CameraCapture.Job.${executionIncrementer.increment()}", jobRepository) - .start(cameraExposureStep(cameraExposureTasklet)) - - val hasDelay = data.exposureDelayInSeconds in 1L..60L - val cameraDelayTasklet = DelayTasklet(data.exposureDelayInSeconds.seconds) - cameraDelayTasklet.subscribe(cameraExposureTasklet) - - repeat(data.exposureAmount - 1) { - if (hasDelay) { - val cameraDelayStep = cameraDelayStep(cameraDelayTasklet) - jobBuilder.next(cameraDelayStep) - } - - val cameraExposureStep = cameraExposureStep(cameraExposureTasklet) - jobBuilder.next(cameraExposureStep) - } - - jobBuilder - .listener(cameraExposureTasklet) - .listener(cameraDelayTasklet) - .build() + sequenceJobFactory.cameraCapture(request, this) } return asyncJobLauncher .run(cameraCaptureJob, JobParameters()) - .let { SequenceJob(listOf(camera), cameraCaptureJob, it) } + .let { CameraSequenceJob(camera, request, cameraCaptureJob, it) } .also(runningSequenceJobs::add) .also { jobRegistry.register(ReferenceJobFactory(cameraCaptureJob)) } } - private fun cameraDelayStep(tasklet: Tasklet) = - StepBuilder("CameraCapture.Step.Delay.${executionIncrementer.increment()}", jobRepository) - .tasklet(tasklet, platformTransactionManager) - .build() - - private fun cameraExposureStep(tasklet: Tasklet) = - StepBuilder("CameraCapture.Step.Exposure.${executionIncrementer.increment()}", jobRepository) - .tasklet(tasklet, platformTransactionManager) - .build() - fun stop(camera: Camera) { val jobExecution = jobExecutionFor(camera) ?: return - jobOperator.stop(jobExecution.jobId) + jobOperator.stop(jobExecution.id) } fun isCapturing(camera: Camera): Boolean { - return sequenceTaskFor(camera)?.jobExecution?.isRunning ?: false + return sequenceJobFor(camera)?.jobExecution?.isRunning ?: false } @Suppress("NOTHING_TO_INLINE") private inline fun jobExecutionFor(camera: Camera): JobExecution? { - return sequenceTaskFor(camera)?.jobExecution + return sequenceJobFor(camera)?.jobExecution } override fun accept(event: CameraCaptureEvent) { messageService.sendMessage(event) } - override fun iterator(): Iterator { + override fun iterator(): Iterator { return runningSequenceJobs.iterator() } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureFinished.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureFinished.kt index 4549b4151..7bf488c1a 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureFinished.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureFinished.kt @@ -1,13 +1,15 @@ package nebulosa.api.cameras +import com.fasterxml.jackson.annotation.JsonIgnore import nebulosa.api.sequencer.SequenceJobEvent import nebulosa.indi.device.camera.Camera import org.springframework.batch.core.JobExecution data class CameraCaptureFinished( override val camera: Camera, - override val jobExecution: JobExecution, + @JsonIgnore override val jobExecution: JobExecution, + @JsonIgnore override val tasklet: CameraExposureTasklet, ) : CameraCaptureEvent, SequenceJobEvent { - override val eventName = "CAMERA_CAPTURE_FINISHED" + @JsonIgnore override val eventName = "CAMERA_CAPTURE_FINISHED" } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureStarted.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureStarted.kt index 74839cff0..db6d16f17 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureStarted.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureStarted.kt @@ -1,13 +1,15 @@ package nebulosa.api.cameras +import com.fasterxml.jackson.annotation.JsonIgnore import nebulosa.api.sequencer.SequenceJobEvent import nebulosa.indi.device.camera.Camera import org.springframework.batch.core.JobExecution data class CameraCaptureStarted( override val camera: Camera, - override val jobExecution: JobExecution, + @JsonIgnore override val jobExecution: JobExecution, + @JsonIgnore override val tasklet: CameraExposureTasklet, ) : CameraCaptureEvent, SequenceJobEvent { - override val eventName = "CAMERA_CAPTURE_STARTED" + @JsonIgnore override val eventName = "CAMERA_CAPTURE_STARTED" } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt index f64dfb1b8..67cbe040e 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt @@ -58,7 +58,7 @@ class CameraController( @PutMapping("{camera}/capture/start") fun startCapture( @EntityBy camera: Camera, - @RequestBody body: CameraStartCapture, + @RequestBody body: CameraStartCaptureRequest, ) { cameraService.startCapture(camera, body) } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraConverter.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraConverter.kt index 65c787069..1078a59b3 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraConverter.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraConverter.kt @@ -1,14 +1,23 @@ package nebulosa.api.cameras import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.SerializerProvider +import nebulosa.api.connection.ConnectionService import nebulosa.indi.device.camera.Camera +import nebulosa.json.FromJson import nebulosa.json.ToJson +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Component import java.nio.file.Path @Component -class CameraConverter(private val capturesPath: Path) : ToJson { +class CameraConverter(private val capturesPath: Path) : ToJson, FromJson { + + @Autowired @Lazy private lateinit var connectionService: ConnectionService override val type = Camera::class.java @@ -67,4 +76,15 @@ class CameraConverter(private val capturesPath: Path) : ToJson { gen.writeObjectField("capturesPath", Path.of("$capturesPath", value.name)) gen.writeEndObject() } + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Camera? { + val node = p.codec.readTree(p) + + val name = if (node.has("camera")) node.get("camera").asText() + else if (node.has("device")) node.get("device").asText() + else return null + + return if (name.isNullOrBlank()) null + else connectionService.camera(name) + } } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureFinished.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureFinished.kt index fe51472c4..520213259 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureFinished.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureFinished.kt @@ -1,5 +1,6 @@ package nebulosa.api.cameras +import com.fasterxml.jackson.annotation.JsonIgnore import nebulosa.api.sequencer.SequenceStepEvent import nebulosa.imaging.Image import nebulosa.indi.device.camera.Camera @@ -8,10 +9,10 @@ import java.nio.file.Path data class CameraExposureFinished( override val camera: Camera, - override val stepExecution: StepExecution, - val image: Image?, - val savePath: Path?, + @JsonIgnore override val stepExecution: StepExecution, + @JsonIgnore override val tasklet: CameraExposureTasklet, + val image: Image?, val savePath: Path?, ) : CameraCaptureEvent, SequenceStepEvent { - override val eventName = "CAMERA_EXPOSURE_FINISHED" + @JsonIgnore override val eventName = "CAMERA_EXPOSURE_FINISHED" } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStarted.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStarted.kt index aa85e3cdb..c18489ff5 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStarted.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStarted.kt @@ -1,13 +1,15 @@ package nebulosa.api.cameras +import com.fasterxml.jackson.annotation.JsonIgnore import nebulosa.api.sequencer.SequenceStepEvent import nebulosa.indi.device.camera.Camera import org.springframework.batch.core.StepExecution data class CameraExposureStarted( override val camera: Camera, - override val stepExecution: StepExecution, + @JsonIgnore override val stepExecution: StepExecution, + @JsonIgnore override val tasklet: CameraExposureTasklet, ) : CameraCaptureEvent, SequenceStepEvent { - override val eventName = "CAMERA_EXPOSURE_STARTED" + @JsonIgnore override val eventName = "CAMERA_EXPOSURE_STARTED" } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureTasklet.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureTasklet.kt index d9ad401de..ade56d53a 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureTasklet.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureTasklet.kt @@ -42,8 +42,8 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.microseconds import kotlin.time.Duration.Companion.seconds -data class CameraExposureTasklet(private val request: CameraStartCapture) : - SubjectSequenceTasklet(), JobExecutionListener, Consumer { +data class CameraExposureTasklet(override val request: CameraStartCaptureRequest) : + SubjectSequenceTasklet(), CameraStartCaptureTasklet, JobExecutionListener, Consumer { private val latch = CountUpDownLatch() private val aborted = AtomicBoolean() @@ -89,14 +89,14 @@ data class CameraExposureTasklet(private val request: CameraStartCapture) : camera.enableBlob() EventBus.getDefault().register(this) jobExecution.executionContext.put(CAPTURE_IN_LOOP, request.isLoop) - onNext(CameraCaptureStarted(camera, jobExecution)) + onNext(CameraCaptureStarted(camera, jobExecution, this)) captureElapsedTime = 0L } override fun afterJob(jobExecution: JobExecution) { camera.disableBlob() EventBus.getDefault().unregister(this) - onNext(CameraCaptureFinished(camera, jobExecution)) + onNext(CameraCaptureFinished(camera, jobExecution, this)) close() } @@ -115,10 +115,9 @@ data class CameraExposureTasklet(private val request: CameraStartCapture) : override fun accept(event: DelayElapsed) { captureElapsedTime += event.waitTime.inWholeMicroseconds - val waitProgress = if (event.remainingTime > Duration.ZERO) 1.0 - event.delayTime / event.remainingTime else 1.0 with(stepExecution!!.executionContext) { - putDouble(WAIT_PROGRESS, waitProgress) + putDouble(WAIT_PROGRESS, event.progress) putLong(WAIT_REMAINING_TIME, event.remainingTime.inWholeMicroseconds) putLong(WAIT_TIME, event.waitTime.inWholeMicroseconds) put(CAPTURE_IS_WAITING, true) @@ -143,9 +142,12 @@ data class CameraExposureTasklet(private val request: CameraStartCapture) : put(CAPTURE_IS_WAITING, false) } - onNext(CameraExposureStarted(camera, stepExecution!!)) + onNext(CameraExposureStarted(camera, stepExecution!!, this)) + + if (request.width > 0 && request.height > 0) { + camera.frame(request.x, request.y, request.width, request.height) + } - camera.frame(request.x, request.y, request.width, request.height) camera.frameType(request.frameType) camera.frameFormat(request.frameFormat) camera.bin(request.binX, request.binY) @@ -180,14 +182,14 @@ data class CameraExposureTasklet(private val request: CameraStartCapture) : try { if (request.saveInMemory) { val image = Image.openFITS(inputStream) - onNext(CameraExposureFinished(camera, stepExecution, image, savePath)) + onNext(CameraExposureFinished(camera, stepExecution, this, image, savePath)) } else { LOG.info("saving FITS at $savePath...") savePath!!.createParentDirectories() inputStream.transferAndClose(savePath.outputStream()) - onNext(CameraExposureFinished(camera, stepExecution, null, savePath)) + onNext(CameraExposureFinished(camera, stepExecution, this, null, savePath)) } } catch (e: Throwable) { LOG.error("failed to save FITS", e) @@ -215,7 +217,7 @@ data class CameraExposureTasklet(private val request: CameraStartCapture) : putLong(CAPTURE_ELAPSED_TIME, elapsedTime.inWholeMicroseconds) } - onNext(CameraExposureUpdated(camera, stepExecution!!)) + onNext(CameraExposureUpdated(camera, stepExecution!!, this)) } companion object { diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureUpdated.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureUpdated.kt index b60373d37..0ca8933c3 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureUpdated.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureUpdated.kt @@ -1,13 +1,15 @@ package nebulosa.api.cameras +import com.fasterxml.jackson.annotation.JsonIgnore import nebulosa.api.sequencer.SequenceStepEvent import nebulosa.indi.device.camera.Camera import org.springframework.batch.core.StepExecution data class CameraExposureUpdated( override val camera: Camera, - override val stepExecution: StepExecution, + @JsonIgnore override val stepExecution: StepExecution, + @JsonIgnore override val tasklet: CameraExposureTasklet, ) : CameraCaptureEvent, SequenceStepEvent { - override val eventName = "CAMERA_EXPOSURE_UPDATED" + @JsonIgnore override val eventName = "CAMERA_EXPOSURE_UPDATED" } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraLoopExposureTasklet.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraLoopExposureTasklet.kt index b67797fa3..f282bf592 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraLoopExposureTasklet.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraLoopExposureTasklet.kt @@ -9,8 +9,8 @@ import org.springframework.batch.core.scope.context.ChunkContext import org.springframework.batch.repeat.RepeatStatus import kotlin.time.Duration.Companion.seconds -data class CameraLoopExposureTasklet(private val request: CameraStartCapture) : - SubjectSequenceTasklet(), JobExecutionListener { +data class CameraLoopExposureTasklet(override val request: CameraStartCaptureRequest) : + SubjectSequenceTasklet(), CameraStartCaptureTasklet, JobExecutionListener { private val exposureTasklet = CameraExposureTasklet(request) private val delayTasklet = DelayTasklet(request.exposureDelayInSeconds.seconds) diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraSequenceJob.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraSequenceJob.kt new file mode 100644 index 000000000..d16338932 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraSequenceJob.kt @@ -0,0 +1,16 @@ +package nebulosa.api.cameras + +import nebulosa.api.sequencer.SequenceJob +import nebulosa.indi.device.camera.Camera +import org.springframework.batch.core.Job +import org.springframework.batch.core.JobExecution + +data class CameraSequenceJob( + val camera: Camera, + val data: CameraStartCaptureRequest, + override val job: Job, + override val jobExecution: JobExecution, +) : SequenceJob { + + override val devices = listOf(camera) +} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt index b84e24199..8d0442dd8 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt @@ -33,7 +33,7 @@ class CameraService( } @Synchronized - fun startCapture(camera: Camera, request: CameraStartCapture) { + fun startCapture(camera: Camera, request: CameraStartCaptureRequest) { if (isCapturing(camera)) return val savePath = request.savePath diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCapture.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt similarity index 81% rename from api/src/main/kotlin/nebulosa/api/cameras/CameraStartCapture.kt rename to api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt index fb1438cf6..fe1da96d8 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCapture.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt @@ -1,14 +1,16 @@ package nebulosa.api.cameras import com.fasterxml.jackson.annotation.JsonIgnore +import jakarta.validation.Valid import jakarta.validation.constraints.Positive import jakarta.validation.constraints.PositiveOrZero +import nebulosa.api.guiding.DitherAfterExposureRequest import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.FrameType import org.hibernate.validator.constraints.Range import java.nio.file.Path -data class CameraStartCapture( +data class CameraStartCaptureRequest( @JsonIgnore val camera: Camera? = null, @field:Positive val exposureInMicroseconds: Long = 0L, @field:Range(min = 0L, max = 1000L) val exposureAmount: Int = 1, // 0 = looping @@ -26,7 +28,8 @@ data class CameraStartCapture( val autoSave: Boolean = false, val savePath: Path? = null, val autoSubFolderMode: AutoSubFolderMode = AutoSubFolderMode.OFF, - @JsonIgnore val saveInMemory: Boolean = false, + @JsonIgnore val saveInMemory: Boolean = savePath == null, + @field:Valid val dither: DitherAfterExposureRequest = DitherAfterExposureRequest.DISABLED, ) { inline val isLoop diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureTasklet.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureTasklet.kt new file mode 100644 index 000000000..dffc38539 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureTasklet.kt @@ -0,0 +1,8 @@ +package nebulosa.api.cameras + +import nebulosa.api.sequencer.SequenceTasklet + +sealed interface CameraStartCaptureTasklet : SequenceTasklet { + + val request: CameraStartCaptureRequest +} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureRequest.kt b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureRequest.kt new file mode 100644 index 000000000..c5a0b4dfe --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureRequest.kt @@ -0,0 +1,16 @@ +package nebulosa.api.guiding + +import jakarta.validation.constraints.Positive + +data class DitherAfterExposureRequest( + val enabled: Boolean = true, + @field:Positive val amount: Double = 1.5, + val raOnly: Boolean = false, + @field:Positive val afterExposures: Int = 1, +) { + + companion object { + + @JvmStatic val DISABLED = DitherAfterExposureRequest(false) + } +} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureTasklet.kt b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureTasklet.kt new file mode 100644 index 000000000..0624bb52c --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureTasklet.kt @@ -0,0 +1,49 @@ +package nebulosa.api.guiding + +import nebulosa.common.concurrency.CountUpDownLatch +import nebulosa.guiding.Guider +import nebulosa.guiding.GuiderListener +import org.springframework.batch.core.StepContribution +import org.springframework.batch.core.scope.context.ChunkContext +import org.springframework.batch.core.step.tasklet.StoppableTasklet +import org.springframework.batch.repeat.RepeatStatus +import org.springframework.beans.factory.annotation.Autowired +import java.util.concurrent.atomic.AtomicInteger + +data class DitherAfterExposureTasklet(val request: DitherAfterExposureRequest) : StoppableTasklet, GuiderListener { + + @Autowired private lateinit var guider: Guider + + private val ditherLatch = CountUpDownLatch() + private val exposureCount = AtomicInteger() + + override fun execute(contribution: StepContribution, chunkContext: ChunkContext): RepeatStatus { + if (request.enabled) { + if (exposureCount.get() < request.afterExposures) { + try { + guider.registerGuiderListener(this) + ditherLatch.countUp() + guider.dither(request.amount, request.raOnly) + ditherLatch.await() + } finally { + guider.unregisterGuiderListener(this) + } + } + + if (exposureCount.incrementAndGet() >= request.afterExposures) { + exposureCount.set(0) + } + } + + return RepeatStatus.FINISHED + } + + override fun stop() { + ditherLatch.reset(0) + guider.unregisterGuiderListener(this) + } + + override fun onDithered(dx: Double, dy: Double) { + ditherLatch.reset() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseElapsed.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseElapsed.kt new file mode 100644 index 000000000..26468f580 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseElapsed.kt @@ -0,0 +1,17 @@ +package nebulosa.api.guiding + +import com.fasterxml.jackson.annotation.JsonIgnore +import nebulosa.api.sequencer.SequenceStepEvent +import nebulosa.guiding.GuideDirection +import org.springframework.batch.core.StepExecution + +data class GuidePulseElapsed( + val remainingTime: Long, + val progress: Double, + val direction: GuideDirection, + @JsonIgnore override val stepExecution: StepExecution, + @JsonIgnore override val tasklet: GuidePulseTasklet, +) : GuidePulseEvent, SequenceStepEvent { + + @JsonIgnore override val eventName = "GUIDE_PULSE_ELAPSED" +} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseEvent.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseEvent.kt new file mode 100644 index 000000000..583009260 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseEvent.kt @@ -0,0 +1,10 @@ +package nebulosa.api.guiding + +import nebulosa.api.sequencer.SequenceStepEvent +import nebulosa.api.sequencer.SequenceTaskletEvent +import nebulosa.api.services.MessageEvent + +sealed interface GuidePulseEvent : MessageEvent, SequenceTaskletEvent, SequenceStepEvent { + + override val tasklet: GuidePulseTasklet +} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseFinished.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseFinished.kt new file mode 100644 index 000000000..48d0913a0 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseFinished.kt @@ -0,0 +1,13 @@ +package nebulosa.api.guiding + +import com.fasterxml.jackson.annotation.JsonIgnore +import nebulosa.api.sequencer.SequenceStepEvent +import org.springframework.batch.core.StepExecution + +data class GuidePulseFinished( + @JsonIgnore override val stepExecution: StepExecution, + @JsonIgnore override val tasklet: GuidePulseTasklet, +) : GuidePulseEvent, SequenceStepEvent { + + @JsonIgnore override val eventName = "GUIDE_PULSE_FINISHED" +} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseRequest.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseRequest.kt new file mode 100644 index 000000000..c0e6a8e3a --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseRequest.kt @@ -0,0 +1,12 @@ +package nebulosa.api.guiding + +import com.fasterxml.jackson.annotation.JsonIgnore +import jakarta.validation.constraints.Positive +import nebulosa.guiding.GuideDirection +import nebulosa.indi.device.guide.GuideOutput + +data class GuidePulseRequest( + @JsonIgnore val guideOutput: GuideOutput? = null, + val direction: GuideDirection, + @field:Positive val durationInMilliseconds: Long, +) diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseStarted.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseStarted.kt new file mode 100644 index 000000000..0037f1104 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseStarted.kt @@ -0,0 +1,12 @@ +package nebulosa.api.guiding + +import com.fasterxml.jackson.annotation.JsonIgnore +import org.springframework.batch.core.StepExecution + +data class GuidePulseStarted( + @JsonIgnore override val stepExecution: StepExecution, + @JsonIgnore override val tasklet: GuidePulseTasklet, +) : GuidePulseEvent { + + @JsonIgnore override val eventName = "GUIDE_PULSE_STARTED" +} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseTasklet.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseTasklet.kt index 10e1345f2..abd052e5a 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseTasklet.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseTasklet.kt @@ -1,41 +1,65 @@ package nebulosa.api.guiding +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.sequencer.SubjectSequenceTasklet +import nebulosa.api.sequencer.tasklets.delay.DelayElapsed +import nebulosa.api.sequencer.tasklets.delay.DelayTasklet import nebulosa.guiding.GuideDirection import nebulosa.indi.device.guide.GuideOutput import org.springframework.batch.core.StepContribution import org.springframework.batch.core.scope.context.ChunkContext -import org.springframework.batch.core.step.tasklet.StoppableTasklet import org.springframework.batch.repeat.RepeatStatus -import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds -data class GuidePulseTasklet( - private val guideOutput: GuideOutput, - private val direction: GuideDirection, private val duration: Duration, -) : StoppableTasklet { +data class GuidePulseTasklet(val request: GuidePulseRequest) : SubjectSequenceTasklet(), Consumer { + + private val delayTasklet = DelayTasklet(request.durationInMilliseconds.milliseconds) + + init { + delayTasklet.subscribe(this) + } override fun execute(contribution: StepContribution, chunkContext: ChunkContext): RepeatStatus { - val durationInMilliseconds = duration.inWholeMilliseconds + val guideOutput = requireNotNull(request.guideOutput) + val durationInMilliseconds = request.durationInMilliseconds + + // Force stop in reversed direction. + guideOutput.pulseGuide(0, request.direction.reversed) - if (pulseGuide(durationInMilliseconds.toInt())) { - Thread.sleep(durationInMilliseconds) + if (guideOutput.pulseGuide(durationInMilliseconds.toInt(), request.direction)) { + delayTasklet.execute(contribution, chunkContext) } return RepeatStatus.FINISHED } override fun stop() { - pulseGuide(0) + request.guideOutput?.pulseGuide(0, request.direction) + delayTasklet.stop() } - private fun pulseGuide(durationInMilliseconds: Int): Boolean { - when (direction) { - GuideDirection.NORTH -> guideOutput.guideNorth(durationInMilliseconds) - GuideDirection.SOUTH -> guideOutput.guideSouth(durationInMilliseconds) - GuideDirection.WEST -> guideOutput.guideWest(durationInMilliseconds) - GuideDirection.EAST -> guideOutput.guideEast(durationInMilliseconds) - else -> return false + override fun accept(event: DelayElapsed) { + if (event.isStarted) onNext(GuidePulseStarted(event.stepExecution, this)) + else if (event.isFinished) onNext(GuidePulseFinished(event.stepExecution, this)) + else { + val remainingTime = event.remainingTime.inWholeMicroseconds + onNext(GuidePulseElapsed(remainingTime, event.progress, request.direction, event.stepExecution, this)) } + } + + companion object { - return true + @JvmStatic + private fun GuideOutput.pulseGuide(durationInMilliseconds: Int, direction: GuideDirection): Boolean { + when (direction) { + GuideDirection.NORTH -> guideNorth(durationInMilliseconds) + GuideDirection.SOUTH -> guideSouth(durationInMilliseconds) + GuideDirection.WEST -> guideWest(durationInMilliseconds) + GuideDirection.EAST -> guideEast(durationInMilliseconds) + else -> return false + } + + return true + } } } diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidingController.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidingController.kt index 9aa73a338..352072792 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/GuidingController.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuidingController.kt @@ -1,6 +1,9 @@ package nebulosa.api.guiding +import jakarta.validation.Valid +import org.hibernate.validator.constraints.Range import org.springframework.web.bind.annotation.* +import kotlin.time.Duration.Companion.seconds @RestController @RequestMapping("guiding") @@ -34,6 +37,7 @@ class GuidingController(private val guidingService: GuidingService) { return guidingService.latestHistory() } + @PutMapping("history/clear") fun clearHistory() { return guidingService.clearHistory() @@ -49,12 +53,21 @@ class GuidingController(private val guidingService: GuidingService) { guidingService.start(forceCalibration) } + @PutMapping("settle") + fun settle( + @RequestParam(required = false) @Valid @Range(min = 1, max = 25) amount: Double?, + @RequestParam(required = false) @Valid @Range(min = 1, max = 60) time: Long?, + @RequestParam(required = false) @Valid @Range(min = 1, max = 60) timeout: Long?, + ) { + guidingService.settle(amount, time?.seconds, timeout?.seconds) + } + @PutMapping("dither") fun dither( - @RequestParam pixels: Double, + @RequestParam amount: Double, @RequestParam(required = false, defaultValue = "false") raOnly: Boolean, ) { - return guidingService.dither(pixels, raOnly) + return guidingService.dither(amount, raOnly) } @PutMapping("stop") diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidingService.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidingService.kt index 65424ab60..b9fd6763d 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/GuidingService.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuidingService.kt @@ -4,26 +4,27 @@ import jakarta.annotation.PreDestroy import nebulosa.api.services.MessageService import nebulosa.guiding.GuideStar import nebulosa.guiding.GuideState +import nebulosa.guiding.Guider import nebulosa.guiding.GuiderListener -import nebulosa.guiding.phd2.PHD2Guider import nebulosa.phd2.client.PHD2Client import nebulosa.phd2.client.PHD2EventListener import nebulosa.phd2.client.commands.PHD2Command import nebulosa.phd2.client.events.PHD2Event import org.springframework.stereotype.Service +import kotlin.time.Duration @Service class GuidingService( private val messageService: MessageService, private val phd2Client: PHD2Client, + private val guider: Guider, ) : PHD2EventListener, GuiderListener { - private val guider = PHD2Guider(phd2Client) private val guideHistory = GuideStepHistory() @Synchronized fun connect(host: String, port: Int) { - if (phd2Client.isOpen) return + check(!phd2Client.isOpen) phd2Client.open(host, port) phd2Client.registerListener(this) @@ -40,7 +41,8 @@ class GuidingService( } fun status(): GuiderStatus { - return GuiderStatus(phd2Client.isOpen, guider.state, guider.isSettling, guider.pixelScale) + return if (!phd2Client.isOpen) GuiderStatus.DISCONNECTED + else GuiderStatus(phd2Client.isOpen, guider.state, guider.isSettling, guider.pixelScale) } fun history(): List { @@ -67,9 +69,15 @@ class GuidingService( } } - fun dither(pixels: Double, raOnly: Boolean = false) { + fun settle(settleAmount: Double?, settleTime: Duration?, settleTimeout: Duration?) { + if (settleAmount != null) guider.settleAmount = settleAmount + if (settleTime != null) guider.settleTime = settleTime + if (settleTimeout != null) guider.settleTimeout = settleTimeout + } + + fun dither(amount: Double, raOnly: Boolean = false) { if (phd2Client.isOpen) { - guider.dither(pixels, raOnly) + guider.dither(amount, raOnly) } } diff --git a/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleTasklet.kt b/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleTasklet.kt new file mode 100644 index 000000000..af47a6808 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleTasklet.kt @@ -0,0 +1,24 @@ +package nebulosa.api.guiding + +import nebulosa.guiding.Guider +import org.springframework.batch.core.StepContribution +import org.springframework.batch.core.scope.context.ChunkContext +import org.springframework.batch.core.step.tasklet.StoppableTasklet +import org.springframework.batch.repeat.RepeatStatus +import org.springframework.beans.factory.annotation.Autowired + +class WaitForSettleTasklet : StoppableTasklet { + + @Autowired private lateinit var guider: Guider + + override fun execute(contribution: StepContribution, chunkContext: ChunkContext): RepeatStatus { + if (guider.isSettling) { + guider.waitForSettle() + } + + return RepeatStatus.FINISHED + } + + override fun stop() { + } +} diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/DelayEvent.kt b/api/src/main/kotlin/nebulosa/api/sequencer/DelayEvent.kt new file mode 100644 index 000000000..792fea382 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/sequencer/DelayEvent.kt @@ -0,0 +1,14 @@ +package nebulosa.api.sequencer + +import kotlin.time.Duration + +interface DelayEvent { + + val remainingTime: Duration + + val delayTime: Duration + + val waitTime: Duration + + val progress: Double +} diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequenceFlowFactory.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceFlowFactory.kt new file mode 100644 index 000000000..6eebacd37 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceFlowFactory.kt @@ -0,0 +1,66 @@ +package nebulosa.api.sequencer + +import nebulosa.api.cameras.CameraExposureTasklet +import nebulosa.api.guiding.GuidePulseTasklet +import nebulosa.api.guiding.WaitForSettleTasklet +import nebulosa.api.sequencer.tasklets.delay.DelayTasklet +import nebulosa.common.concurrency.Incrementer +import org.springframework.batch.core.job.builder.FlowBuilder +import org.springframework.batch.core.job.flow.support.SimpleFlow +import org.springframework.beans.factory.config.ConfigurableBeanFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Scope +import org.springframework.core.task.SimpleAsyncTaskExecutor + +@Configuration +class SequenceFlowFactory( + private val flowIncrementer: Incrementer, + private val sequenceStepFactory: SequenceStepFactory, + private val simpleAsyncTaskExecutor: SimpleAsyncTaskExecutor, +) { + + @Bean(name = ["cameraExposureFlow"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun cameraExposure(cameraExposureTasklet: CameraExposureTasklet): SimpleFlow { + val step = sequenceStepFactory.cameraExposure(cameraExposureTasklet) + return FlowBuilder("Flow.CameraExposure.${flowIncrementer.increment()}").start(step).end() + } + + @Bean(name = ["delayFlow"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun delay(delayTasklet: DelayTasklet): SimpleFlow { + val step = sequenceStepFactory.delay(delayTasklet) + return FlowBuilder("Flow.Delay.${flowIncrementer.increment()}").start(step).end() + } + + @Bean(name = ["waitForSettleFlow"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun waitForSettle(waitForSettleTasklet: WaitForSettleTasklet): SimpleFlow { + val step = sequenceStepFactory.waitForSettle(waitForSettleTasklet) + return FlowBuilder("Flow.WaitForSettle.${flowIncrementer.increment()}").start(step).end() + } + + @Bean(name = ["guidePulseFlow"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun guidePulse( + initialPauseDelayTasklet: DelayTasklet, + forwardGuidePulseTasklet: GuidePulseTasklet, backwardGuidePulseTasklet: GuidePulseTasklet + ): SimpleFlow { + return FlowBuilder("Flow.GuidePulse.${flowIncrementer.increment()}") + .start(sequenceStepFactory.delay(initialPauseDelayTasklet)) + .next(sequenceStepFactory.guidePulse(forwardGuidePulseTasklet)) + .next(sequenceStepFactory.guidePulse(backwardGuidePulseTasklet)) + .end() + } + + @Bean(name = ["delayAndWaitForSettleFlow"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun delayAndWaitForSettle(cameraDelayTasklet: DelayTasklet, waitForSettleTasklet: WaitForSettleTasklet): SimpleFlow { + return FlowBuilder("Flow.DelayAndWaitForSettle.${flowIncrementer.increment()}") + .start(delay(cameraDelayTasklet)) + .split(simpleAsyncTaskExecutor) + .add(waitForSettle(waitForSettleTasklet)) + .end() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequenceFlowStepFactory.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceFlowStepFactory.kt new file mode 100644 index 000000000..441e85cd2 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceFlowStepFactory.kt @@ -0,0 +1,28 @@ +package nebulosa.api.sequencer + +import nebulosa.api.guiding.WaitForSettleTasklet +import nebulosa.api.sequencer.tasklets.delay.DelayTasklet +import nebulosa.common.concurrency.Incrementer +import org.springframework.batch.core.Step +import org.springframework.batch.core.repository.JobRepository +import org.springframework.batch.core.step.builder.StepBuilder +import org.springframework.beans.factory.config.ConfigurableBeanFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Scope + +@Configuration +class SequenceFlowStepFactory( + private val jobRepository: JobRepository, + private val stepIncrementer: Incrementer, + private val sequenceFlowFactory: SequenceFlowFactory, +) { + + @Bean(name = ["delayAndWaitForSettleFlowStep"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun delayAndWaitForSettle(cameraDelayTasklet: DelayTasklet, waitForSettleTasklet: WaitForSettleTasklet): Step { + return StepBuilder("FlowStep.DelayAndWaitForSettle.${stepIncrementer.increment()}", jobRepository) + .flow(sequenceFlowFactory.delayAndWaitForSettle(cameraDelayTasklet, waitForSettleTasklet)) + .build() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequenceJob.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceJob.kt index cbf1ea9bd..b9fd3d3fd 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequenceJob.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceJob.kt @@ -4,11 +4,13 @@ import nebulosa.indi.device.Device import org.springframework.batch.core.Job import org.springframework.batch.core.JobExecution -data class SequenceJob( - val devices: List, - val job: Job, - val jobExecution: JobExecution, -) { +interface SequenceJob { + + val devices: List + + val job: Job + + val jobExecution: JobExecution val jobId get() = jobExecution.jobId diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequenceJobExecutor.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceJobExecutor.kt index 1e49d397e..7ee1e65a2 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequenceJobExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceJobExecutor.kt @@ -2,12 +2,12 @@ package nebulosa.api.sequencer import nebulosa.indi.device.Device -interface SequenceJobExecutor : Iterable { +interface SequenceJobExecutor : Iterable { - fun execute(data: T): SequenceJob + fun execute(request: T): J - fun sequenceTaskFor(vararg devices: Device): SequenceJob? { - fun find(task: SequenceJob): Boolean { + fun sequenceJobFor(vararg devices: Device): J? { + fun find(task: J): Boolean { for (i in devices.indices) { if (i >= task.devices.size || task.devices[i].name != devices[i].name) { return false @@ -19,4 +19,8 @@ interface SequenceJobExecutor : Iterable { return findLast(::find) } + + fun sequenceJobWithId(jobId: Long): J? { + return find { it.jobId == jobId } + } } diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequenceJobFactory.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceJobFactory.kt new file mode 100644 index 000000000..b388516ce --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceJobFactory.kt @@ -0,0 +1,72 @@ +package nebulosa.api.sequencer + +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.cameras.CameraCaptureEvent +import nebulosa.api.cameras.CameraStartCaptureRequest +import nebulosa.common.concurrency.Incrementer +import org.springframework.batch.core.Job +import org.springframework.batch.core.job.builder.JobBuilder +import org.springframework.batch.core.repository.JobRepository +import org.springframework.beans.factory.config.ConfigurableBeanFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Scope +import kotlin.time.Duration.Companion.seconds + +@Configuration +class SequenceJobFactory( + private val jobRepository: JobRepository, + private val sequenceFlowStepFactory: SequenceFlowStepFactory, + private val sequenceStepFactory: SequenceStepFactory, + private val sequenceTaskletFactory: SequenceTaskletFactory, + private val jobIncrementer: Incrementer, +) { + + @Bean(name = ["cameraLoopCaptureJob"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun cameraLoopCapture( + request: CameraStartCaptureRequest, + cameraCaptureListener: Consumer, + ): Job { + val cameraExposureTasklet = sequenceTaskletFactory.cameraLoopExposure(request) + cameraExposureTasklet.subscribe(cameraCaptureListener) + + val cameraExposureStep = sequenceStepFactory.cameraExposure(cameraExposureTasklet) + + return JobBuilder("CameraCapture.Job.${jobIncrementer.increment()}", jobRepository) + .start(cameraExposureStep) + .listener(cameraExposureTasklet) + .build() + } + + @Bean(name = ["cameraCaptureJob"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun cameraCapture( + request: CameraStartCaptureRequest, + cameraCaptureListener: Consumer, + ): Job { + val cameraExposureTasklet = sequenceTaskletFactory.cameraExposure(request) + cameraExposureTasklet.subscribe(cameraCaptureListener) + + val cameraDelayTasklet = sequenceTaskletFactory.delay(request.exposureDelayInSeconds.seconds) + cameraDelayTasklet.subscribe(cameraExposureTasklet) + + val ditherTasklet = sequenceTaskletFactory.ditherAfterExposure(request.dither) + val waitForSettleTasklet = sequenceTaskletFactory.waitForSettle() + + val jobBuilder = JobBuilder("CameraCapture.Job.${jobIncrementer.increment()}", jobRepository) + .start(sequenceStepFactory.waitForSettle(waitForSettleTasklet)) + .next(sequenceStepFactory.cameraExposure(cameraExposureTasklet)) + + repeat(request.exposureAmount - 1) { + jobBuilder.next(sequenceFlowStepFactory.delayAndWaitForSettle(cameraDelayTasklet, waitForSettleTasklet)) + .next(sequenceStepFactory.cameraExposure(cameraExposureTasklet)) + .next(sequenceStepFactory.dither(ditherTasklet)) + } + + return jobBuilder + .listener(cameraExposureTasklet) + .listener(cameraDelayTasklet) + .build() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequenceStepFactory.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceStepFactory.kt new file mode 100644 index 000000000..a7dcd2931 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceStepFactory.kt @@ -0,0 +1,64 @@ +package nebulosa.api.sequencer + +import nebulosa.api.cameras.CameraStartCaptureTasklet +import nebulosa.api.guiding.DitherAfterExposureTasklet +import nebulosa.api.guiding.GuidePulseTasklet +import nebulosa.api.guiding.WaitForSettleTasklet +import nebulosa.api.sequencer.tasklets.delay.DelayTasklet +import nebulosa.common.concurrency.Incrementer +import org.springframework.batch.core.repository.JobRepository +import org.springframework.batch.core.step.builder.StepBuilder +import org.springframework.batch.core.step.tasklet.TaskletStep +import org.springframework.beans.factory.config.ConfigurableBeanFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Scope +import org.springframework.transaction.PlatformTransactionManager + +@Configuration +class SequenceStepFactory( + private val jobRepository: JobRepository, + private val platformTransactionManager: PlatformTransactionManager, + private val stepIncrementer: Incrementer, +) { + + @Bean(name = ["delayStep"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun delay(delayTasklet: DelayTasklet): TaskletStep { + return StepBuilder("Step.Delay.${stepIncrementer.increment()}", jobRepository) + .tasklet(delayTasklet, platformTransactionManager) + .build() + } + + @Bean(name = ["cameraExposureStep"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun cameraExposure(cameraExposureTasklet: CameraStartCaptureTasklet): TaskletStep { + return StepBuilder("Step.Exposure.${stepIncrementer.increment()}", jobRepository) + .tasklet(cameraExposureTasklet, platformTransactionManager) + .build() + } + + @Bean(name = ["guidePulseStep"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun guidePulse(guidePulseTasklet: GuidePulseTasklet): TaskletStep { + return StepBuilder("Step.GuidePulse.${stepIncrementer.increment()}", jobRepository) + .tasklet(guidePulseTasklet, platformTransactionManager) + .build() + } + + @Bean(name = ["ditherStep"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun dither(ditherAfterExposureTasklet: DitherAfterExposureTasklet): TaskletStep { + return StepBuilder("Step.DitherAfterExposure.${stepIncrementer.increment()}", jobRepository) + .tasklet(ditherAfterExposureTasklet, platformTransactionManager) + .build() + } + + @Bean(name = ["waitForSettleStep"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun waitForSettle(waitForSettleTasklet: WaitForSettleTasklet): TaskletStep { + return StepBuilder("Step.WaitForSettle.${stepIncrementer.increment()}", jobRepository) + .tasklet(waitForSettleTasklet, platformTransactionManager) + .build() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequenceTaskletEvent.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceTaskletEvent.kt new file mode 100644 index 000000000..919b377e6 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceTaskletEvent.kt @@ -0,0 +1,8 @@ +package nebulosa.api.sequencer + +import org.springframework.batch.core.step.tasklet.Tasklet + +interface SequenceTaskletEvent { + + val tasklet: Tasklet +} diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequenceTaskletFactory.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceTaskletFactory.kt new file mode 100644 index 000000000..0ba49fdf5 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequenceTaskletFactory.kt @@ -0,0 +1,52 @@ +package nebulosa.api.sequencer + +import nebulosa.api.cameras.CameraExposureTasklet +import nebulosa.api.cameras.CameraLoopExposureTasklet +import nebulosa.api.cameras.CameraStartCaptureRequest +import nebulosa.api.guiding.* +import nebulosa.api.sequencer.tasklets.delay.DelayTasklet +import org.springframework.beans.factory.config.ConfigurableBeanFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Scope +import kotlin.time.Duration + +@Configuration +class SequenceTaskletFactory { + + @Bean(name = ["delayTasklet"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun delay(duration: Duration): DelayTasklet { + return DelayTasklet(duration) + } + + @Bean(name = ["cameraExposureTasklet"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun cameraExposure(request: CameraStartCaptureRequest): CameraExposureTasklet { + return CameraExposureTasklet(request) + } + + @Bean(name = ["cameraLoopExposureTasklet"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun cameraLoopExposure(request: CameraStartCaptureRequest): CameraLoopExposureTasklet { + return CameraLoopExposureTasklet(request) + } + + @Bean(name = ["guidePulseTasklet"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun guidePulse(request: GuidePulseRequest): GuidePulseTasklet { + return GuidePulseTasklet(request) + } + + @Bean(name = ["ditherTasklet"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) + fun ditherAfterExposure(request: DitherAfterExposureRequest): DitherAfterExposureTasklet { + return DitherAfterExposureTasklet(request) + } + + @Bean(name = ["waitForSettleTasklet"], autowireCandidate = false) + @Scope(value = ConfigurableBeanFactory.SCOPE_SINGLETON) + fun waitForSettle(): WaitForSettleTasklet { + return WaitForSettleTasklet() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SubjectSequenceTasklet.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SubjectSequenceTasklet.kt index 963470679..1bc664f23 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SubjectSequenceTasklet.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SubjectSequenceTasklet.kt @@ -5,9 +5,11 @@ import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.functions.Consumer import io.reactivex.rxjava3.subjects.PublishSubject import io.reactivex.rxjava3.subjects.Subject +import nebulosa.log.debug +import nebulosa.log.loggerFor import java.io.Closeable -abstract class SubjectSequenceTasklet(@JvmField protected val subject: Subject) : SequenceTasklet, Closeable { +abstract class SubjectSequenceTasklet(@JvmField protected val subject: Subject) : SequenceTasklet, Closeable { constructor() : this(PublishSubject.create()) @@ -25,6 +27,7 @@ abstract class SubjectSequenceTasklet(@JvmField protected val subject: @Synchronized final override fun onNext(event: T) { + LOG.debug { "$event" } subject.onNext(event) } @@ -41,4 +44,9 @@ abstract class SubjectSequenceTasklet(@JvmField protected val subject: final override fun close() { onComplete() } + + companion object { + + @JvmStatic private val LOG = loggerFor>() + } } diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/tasklets/delay/DelayElapsed.kt b/api/src/main/kotlin/nebulosa/api/sequencer/tasklets/delay/DelayElapsed.kt index 7e274c70c..81a4c2ed7 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/tasklets/delay/DelayElapsed.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/tasklets/delay/DelayElapsed.kt @@ -1,9 +1,26 @@ package nebulosa.api.sequencer.tasklets.delay +import com.fasterxml.jackson.annotation.JsonIgnore +import nebulosa.api.sequencer.DelayEvent +import nebulosa.api.sequencer.SequenceStepEvent +import nebulosa.api.sequencer.SequenceTaskletEvent +import org.springframework.batch.core.StepExecution import kotlin.time.Duration data class DelayElapsed( - val remainingTime: Duration, - val delayTime: Duration, - val waitTime: Duration, -) + override val remainingTime: Duration, + override val delayTime: Duration, + override val waitTime: Duration, + @JsonIgnore override val stepExecution: StepExecution, + @JsonIgnore override val tasklet: DelayTasklet, +) : SequenceStepEvent, SequenceTaskletEvent, DelayEvent { + + override val progress + get() = if (remainingTime > Duration.ZERO) 1.0 - delayTime / remainingTime else 1.0 + + inline val isStarted + get() = remainingTime == delayTime + + inline val isFinished + get() = remainingTime == Duration.ZERO +} diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/tasklets/delay/DelayTasklet.kt b/api/src/main/kotlin/nebulosa/api/sequencer/tasklets/delay/DelayTasklet.kt index e6faece33..dfe95d9cf 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/tasklets/delay/DelayTasklet.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/tasklets/delay/DelayTasklet.kt @@ -16,7 +16,8 @@ data class DelayTasklet(private val duration: Duration) : SubjectSequenceTasklet private val aborted = AtomicBoolean() override fun execute(contribution: StepContribution, chunkContext: ChunkContext): RepeatStatus { - val delayTimeInMilliseconds = contribution.stepExecution.executionContext + val stepExecution = contribution.stepExecution + val delayTimeInMilliseconds = stepExecution.executionContext .getLong(DELAY_TIME_NAME, duration.inWholeMilliseconds) val delayTime = delayTimeInMilliseconds.milliseconds @@ -27,13 +28,13 @@ data class DelayTasklet(private val duration: Duration) : SubjectSequenceTasklet val waitTime = min(remainingTime, DELAY_INTERVAL) if (waitTime > 0) { - onNext(DelayElapsed(remainingTime.milliseconds, delayTime, waitTime.milliseconds)) + onNext(DelayElapsed(remainingTime.milliseconds, delayTime, waitTime.milliseconds, stepExecution, this)) Thread.sleep(waitTime) remainingTime -= waitTime } } - onNext(DelayElapsed(Duration.ZERO, delayTime, Duration.ZERO)) + onNext(DelayElapsed(Duration.ZERO, delayTime, Duration.ZERO, stepExecution, this)) } return RepeatStatus.FINISHED diff --git a/api/src/main/kotlin/nebulosa/api/services/MessageService.kt b/api/src/main/kotlin/nebulosa/api/services/MessageService.kt index f273c480b..cc54974b1 100644 --- a/api/src/main/kotlin/nebulosa/api/services/MessageService.kt +++ b/api/src/main/kotlin/nebulosa/api/services/MessageService.kt @@ -1,6 +1,8 @@ package nebulosa.api.services import com.fasterxml.jackson.databind.ObjectMapper +import nebulosa.log.debug +import nebulosa.log.loggerFor import org.springframework.messaging.simp.SimpMessagingTemplate import org.springframework.stereotype.Service @@ -11,6 +13,7 @@ class MessageService( ) { fun sendMessage(eventName: String, payload: Any) { + LOG.debug { "$eventName: $payload" } simpleMessageTemplate.convertAndSend(eventName, payload) } @@ -23,4 +26,9 @@ class MessageService( fun sendMessage(event: MessageEvent) { sendMessage(event.eventName, event) } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } } diff --git a/desktop/README.md b/desktop/README.md index 98b2836e3..611f8aa58 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -47,6 +47,10 @@ The complete integrated solution for all of your astronomical imaging needs. ![](framing.png) +## Alignment + +![](alignment.darv.png) + ## INDI ![](indi.png) diff --git a/desktop/alignment.darv.png b/desktop/alignment.darv.png new file mode 100644 index 000000000..c565d344b Binary files /dev/null and b/desktop/alignment.darv.png differ diff --git a/desktop/app/main.ts b/desktop/app/main.ts index fb5a30231..7951aecd6 100644 --- a/desktop/app/main.ts +++ b/desktop/app/main.ts @@ -56,10 +56,11 @@ function createMainWindow() { } function createWindow(data: OpenWindow) { - if (browserWindows.has(data.id)) { - const window = browserWindows.get(data.id)! + let window = browserWindows.get(data.id) + if (window) { if (data.params) { + console.info('params changed. id=%s, params=%s', data.id, data.params) window.webContents.send('PARAMS_CHANGED', data.params) } @@ -98,7 +99,7 @@ function createWindow(data: OpenWindow) { const icon = data.icon ?? 'nebulosa' const params = encodeURIComponent(JSON.stringify(data.params || {})) - const window = new BrowserWindow({ + window = new BrowserWindow({ title: 'Nebulosa', frame: false, width, height, diff --git a/desktop/guiding.png b/desktop/guiding.png index a2b8b0e07..2fbe8f214 100644 Binary files a/desktop/guiding.png and b/desktop/guiding.png differ diff --git a/desktop/src/app/alignment/alignment.component.html b/desktop/src/app/alignment/alignment.component.html new file mode 100644 index 000000000..9f2b978bf --- /dev/null +++ b/desktop/src/app/alignment/alignment.component.html @@ -0,0 +1,95 @@ +
+
+
+
+
+ + + + +
+
+ + +
+
+
+
+
+
+ + + + +
+
+ + +
+
+
+
+ + {{ darvStatus }} + + + {{ darvDirection }} + + + {{ darvCaptureEvent.captureRemainingTime | exposureTime }} + + + {{ darvCaptureEvent.captureProgress | percent:'1.1-1' }} + + +
+
+
+
+ + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+
+
+ + + +
+
+
+
+
+
+
\ No newline at end of file diff --git a/desktop/src/app/alignment/alignment.component.scss b/desktop/src/app/alignment/alignment.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/desktop/src/app/alignment/alignment.component.ts b/desktop/src/app/alignment/alignment.component.ts new file mode 100644 index 000000000..340bdffa2 --- /dev/null +++ b/desktop/src/app/alignment/alignment.component.ts @@ -0,0 +1,187 @@ +import { AfterViewInit, Component, HostListener, NgZone, OnDestroy } from '@angular/core' +import { ApiService } from '../../shared/services/api.service' +import { BrowserWindowService } from '../../shared/services/browser-window.service' +import { ElectronService } from '../../shared/services/electron.service' +import { PreferenceService } from '../../shared/services/preference.service' +import { Camera, CameraCaptureEvent, DARVPolarAlignmentEvent, DARVPolarAlignmentGuidePulseElapsed, DARVPolarAlignmentInitialPauseElapsed, GuideDirection, GuideOutput, Hemisphere } from '../../shared/types' +import { AppComponent } from '../app.component' + +@Component({ + selector: 'app-alignment', + templateUrl: './alignment.component.html', + styleUrls: ['./alignment.component.scss'], +}) +export class AlignmentComponent implements AfterViewInit, OnDestroy { + + cameras: Camera[] = [] + camera?: Camera + cameraConnected = false + + guideOutputs: GuideOutput[] = [] + guideOutput?: GuideOutput + guideOutputConnected = false + + darvInitialPause = 5 + darvDrift = 30 + darvInProgress = false + readonly darvHemispheres: Hemisphere[] = ['NORTHERN', 'SOUTHERN'] + darvHemisphere: Hemisphere = 'NORTHERN' + darvCaptureEvent?: CameraCaptureEvent + darvDirection?: GuideDirection + darvStatus = 'idle' + + constructor( + app: AppComponent, + private api: ApiService, + private browserWindow: BrowserWindowService, + private electron: ElectronService, + private preference: PreferenceService, + ngZone: NgZone, + ) { + app.title = 'Alignment' + + electron.on('CAMERA_UPDATED', (_, event: Camera) => { + if (event.name === this.camera?.name) { + ngZone.run(() => { + Object.assign(this.camera!, event) + this.updateCamera() + }) + } + }) + + electron.on('GUIDE_OUTPUT_UPDATED', (_, event: GuideOutput) => { + if (event.name === this.guideOutput?.name) { + ngZone.run(() => { + Object.assign(this.guideOutput!, event) + this.updateGuideOutput() + }) + } + }) + + electron.on('DARV_POLAR_ALIGNMENT_STARTED', (_, event: DARVPolarAlignmentEvent) => { + if (event.camera.name === this.camera?.name && + event.guideOutput.name === this.guideOutput?.name) { + ngZone.run(() => { + this.darvInProgress = true + }) + } + }) + + electron.on('DARV_POLAR_ALIGNMENT_FINISHED', (_, event: DARVPolarAlignmentEvent) => { + if (event.camera.name === this.camera?.name && + event.guideOutput.name === this.guideOutput?.name) { + ngZone.run(() => { + this.darvInProgress = false + this.darvStatus = 'idle' + this.darvDirection = undefined + }) + } + }) + + electron.on('DARV_POLAR_ALIGNMENT_INITIAL_PAUSE_ELAPSED', (_, event: DARVPolarAlignmentInitialPauseElapsed) => { + if (event.camera.name === this.camera?.name && + event.guideOutput.name === this.guideOutput?.name) { + ngZone.run(() => { + this.darvStatus = 'initial pause' + }) + } + }) + + electron.on('DARV_POLAR_ALIGNMENT_GUIDE_PULSE_ELAPSED', (_, event: DARVPolarAlignmentGuidePulseElapsed) => { + if (event.camera.name === this.camera?.name && + event.guideOutput.name === this.guideOutput?.name) { + ngZone.run(() => { + this.darvDirection = event.direction + this.darvStatus = event.forward ? 'forwarding' : 'backwarding' + }) + } + }) + + electron.on('CAMERA_EXPOSURE_UPDATED', (_, event: CameraCaptureEvent) => { + if (event.camera.name === this.camera?.name) { + ngZone.run(() => { + this.darvCaptureEvent = event + }) + } + }) + } + + async ngAfterViewInit() { + this.cameras = await this.api.cameras() + this.guideOutputs = await this.api.guideOutputs() + } + + @HostListener('window:unload') + ngOnDestroy() { + this.darvStop() + } + + async cameraChanged() { + if (this.camera) { + const camera = await this.api.camera(this.camera.name) + Object.assign(this.camera, camera) + + this.updateCamera() + } + } + + async guideOutputChanged() { + if (this.guideOutput) { + const guideOutput = await this.api.guideOutput(this.guideOutput.name) + Object.assign(this.guideOutput, guideOutput) + + this.updateGuideOutput() + } + } + + cameraConnect() { + if (this.cameraConnected) { + this.api.cameraDisconnect(this.camera!) + } else { + this.api.cameraConnect(this.camera!) + } + } + + guideOutputConnect() { + if (this.guideOutputConnected) { + this.api.guideOutputDisconnect(this.guideOutput!) + } else { + this.api.guideOutputConnect(this.guideOutput!) + } + } + + private async darvStart(direction: GuideDirection) { + // TODO: Horizonte leste e oeste tem um impacto no "reversed"? + const reversed = this.darvHemisphere === 'SOUTHERN' + await this.browserWindow.openCameraImage(this.camera!) + await this.api.darvStart(this.camera!, this.guideOutput!, this.darvDrift, this.darvInitialPause, direction, reversed) + } + + darvAzimuth() { + this.darvStart('EAST') + } + + darvAltitude() { + this.darvStart('EAST') // TODO: NORTH não é usado? + } + + darvStop() { + this.api.darvStop(this.camera!, this.guideOutput!) + } + + private async updateCamera() { + if (!this.camera) { + return + } + + this.cameraConnected = this.camera.connected + } + + private async updateGuideOutput() { + if (!this.guideOutput) { + return + } + + this.guideOutputConnected = this.guideOutput.connected + } +} \ No newline at end of file diff --git a/desktop/src/app/app-routing.module.ts b/desktop/src/app/app-routing.module.ts index d2c144b9b..dc63f7892 100644 --- a/desktop/src/app/app-routing.module.ts +++ b/desktop/src/app/app-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core' import { RouterModule, Routes } from '@angular/router' import { APP_CONFIG } from '../environments/environment' import { AboutComponent } from './about/about.component' +import { AlignmentComponent } from './alignment/alignment.component' import { AtlasComponent } from './atlas/atlas.component' import { CameraComponent } from './camera/camera.component' import { FilterWheelComponent } from './filterwheel/filterwheel.component' @@ -59,6 +60,10 @@ const routes: Routes = [ path: 'framing', component: FramingComponent, }, + { + path: 'alignment', + component: AlignmentComponent, + }, { path: 'about', component: AboutComponent, diff --git a/desktop/src/app/app.component.html b/desktop/src/app/app.component.html index 8d6ffc830..3ce2113b1 100644 --- a/desktop/src/app/app.component.html +++ b/desktop/src/app/app.component.html @@ -1,10 +1,16 @@ -
+
{{ title }} - - - - - + + + + +
diff --git a/desktop/src/app/app.component.scss b/desktop/src/app/app.component.scss index 89a6d1f22..e69de29bb 100644 --- a/desktop/src/app/app.component.scss +++ b/desktop/src/app/app.component.scss @@ -1,3 +0,0 @@ -:host { - -} \ No newline at end of file diff --git a/desktop/src/app/app.module.ts b/desktop/src/app/app.module.ts index 907cd712f..dad16113a 100644 --- a/desktop/src/app/app.module.ts +++ b/desktop/src/app/app.module.ts @@ -40,6 +40,7 @@ import { EnvPipe } from '../shared/pipes/env.pipe' import { ExposureTimePipe } from '../shared/pipes/exposureTime.pipe' import { WinPipe } from '../shared/pipes/win.pipe' import { AboutComponent } from './about/about.component' +import { AlignmentComponent } from './alignment/alignment.component' import { AppRoutingModule } from './app-routing.module' import { AppComponent } from './app.component' import { AtlasComponent } from './atlas/atlas.component' @@ -72,6 +73,7 @@ import { MountComponent } from './mount/mount.component' MountComponent, GuiderComponent, DialogMenuComponent, + AlignmentComponent, LocationDialog, EnvPipe, WinPipe, diff --git a/desktop/src/app/atlas/atlas.component.html b/desktop/src/app/atlas/atlas.component.html index bd713eb6a..adfe9bb91 100644 --- a/desktop/src/app/atlas/atlas.component.html +++ b/desktop/src/app/atlas/atlas.component.html @@ -56,7 +56,7 @@
+ severity="success" size="small" />
@@ -90,8 +90,8 @@
- - + +
@@ -129,8 +129,8 @@
- - + +
@@ -169,9 +169,9 @@
+ size="small" severity="info" /> + size="small" />
@@ -212,10 +212,10 @@
- - + + + size="small" severity="danger" />
@@ -302,13 +302,12 @@
- - - - + + + +
@@ -402,7 +401,7 @@ - + @@ -468,7 +467,7 @@ - + @@ -482,8 +481,7 @@ - - + + \ No newline at end of file diff --git a/desktop/src/app/atlas/atlas.component.ts b/desktop/src/app/atlas/atlas.component.ts index 98dbc17ed..162636b83 100644 --- a/desktop/src/app/atlas/atlas.component.ts +++ b/desktop/src/app/atlas/atlas.component.ts @@ -345,8 +345,6 @@ export class AtlasComponent implements OnInit, AfterContentInit, OnDestroy { readonly altitudeOptions: ChartOptions = { responsive: true, - aspectRatio: 1.8, - maintainAspectRatio: false, plugins: { legend: { display: false, @@ -406,8 +404,8 @@ export class AtlasComponent implements OnInit, AfterContentInit, OnDestroy { scales: { y: { beginAtZero: true, - suggestedMin: 0, - suggestedMax: 90, + min: 0, + max: 90, ticks: { autoSkip: false, count: 10, diff --git a/desktop/src/app/camera/camera.component.html b/desktop/src/app/camera/camera.component.html index 97dd7d0b2..1fd6f26a7 100644 --- a/desktop/src/app/camera/camera.component.html +++ b/desktop/src/app/camera/camera.component.html @@ -9,13 +9,13 @@
+ size="small" severity="danger" pTooltip="Disconnect" tooltipPosition="bottom" /> + size="small" severity="info" pTooltip="Connect" tooltipPosition="bottom" />
- +
@@ -86,11 +86,12 @@ + [step]="0.1" suffix="℃" [min]="-50" [max]="50" locale="en" styleClass="p-inputtext-sm border-0" + [allowEmpty]="false" /> + severity="sucsess" pTooltip="Apply" tooltipPosition="bottom" />
@@ -110,7 +111,8 @@
- @@ -119,13 +121,15 @@
Exposure Mode -
@@ -133,7 +137,8 @@
@@ -180,19 +185,21 @@
+ severity="info" pTooltip="Full size" tooltipPosition="bottom" />
+ [step]="1.0" [min]="1" [max]="4" styleClass="p-inputtext-sm border-0" [allowEmpty]="false" + (ngModelChange)="savePreference()" />
+ [step]="1.0" [min]="1" [max]="4" styleClass="p-inputtext-sm border-0" [allowEmpty]="false" + (ngModelChange)="savePreference()" />
@@ -200,7 +207,8 @@
- @@ -225,9 +233,9 @@
+ severity="sucsess" /> + severity="danger" />
diff --git a/desktop/src/app/filterwheel/filterwheel.component.html b/desktop/src/app/filterwheel/filterwheel.component.html index d0d130463..9c02bd622 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.html +++ b/desktop/src/app/filterwheel/filterwheel.component.html @@ -10,9 +10,9 @@
+ size="small" severity="danger" /> + size="small" severity="info" />
@@ -28,7 +28,8 @@
-
@@ -47,11 +48,11 @@ - - + + diff --git a/desktop/src/app/filterwheel/filterwheel.component.ts b/desktop/src/app/filterwheel/filterwheel.component.ts index 515a57f3c..b09fb4e70 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.ts +++ b/desktop/src/app/filterwheel/filterwheel.component.ts @@ -81,11 +81,11 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { this.electron.send('WHEEL_CHANGED', this.wheel) } - async connect() { + connect() { if (this.connected) { - await this.api.wheelDisconnect(this.wheel!) + this.api.wheelDisconnect(this.wheel!) } else { - await this.api.wheelConnect(this.wheel!) + this.api.wheelConnect(this.wheel!) } } diff --git a/desktop/src/app/focuser/focuser.component.html b/desktop/src/app/focuser/focuser.component.html index c0cc21c97..c42d97b56 100644 --- a/desktop/src/app/focuser/focuser.component.html +++ b/desktop/src/app/focuser/focuser.component.html @@ -9,9 +9,9 @@
+ size="small" severity="danger" pTooltip="Disconnect" tooltipPosition="bottom" /> + size="small" severity="info" pTooltip="Connect" tooltipPosition="bottom" />
@@ -33,19 +33,19 @@
-
+ [text]="true" size="small" pTooltip="Move In" tooltipPosition="bottom" /> + [text]="true" size="small" pTooltip="Move Out" tooltipPosition="bottom" />
@@ -54,9 +54,9 @@ + [text]="true" severity="success" size="small" pTooltip="Move To" tooltipPosition="bottom" /> + [text]="true" severity="info" size="small" pTooltip="Sync" tooltipPosition="bottom" />
\ No newline at end of file diff --git a/desktop/src/app/focuser/focuser.component.ts b/desktop/src/app/focuser/focuser.component.ts index b16dde777..3365fb6ca 100644 --- a/desktop/src/app/focuser/focuser.component.ts +++ b/desktop/src/app/focuser/focuser.component.ts @@ -75,11 +75,11 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { this.electron.send('FOCUSER_CHANGED', this.focuser) } - async connect() { + connect() { if (this.connected) { - await this.api.focuserDisconnect(this.focuser!) + this.api.focuserDisconnect(this.focuser!) } else { - await this.api.focuserConnect(this.focuser!) + this.api.focuserConnect(this.focuser!) } } diff --git a/desktop/src/app/framing/framing.component.html b/desktop/src/app/framing/framing.component.html index 3b0ae1262..5f5242f5e 100644 --- a/desktop/src/app/framing/framing.component.html +++ b/desktop/src/app/framing/framing.component.html @@ -59,7 +59,7 @@
- +
-
+
+ North + East
-
+
- + icon="mdi mdi-play" severity="success" /> --> + + severity="danger" />
+ +
+ + + + + + + +
+
+
+
+ + + + +
+
+
+ + + + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + + + +
+
+
+ + + + +
+
+
+
\ No newline at end of file diff --git a/desktop/src/app/guider/guider.component.ts b/desktop/src/app/guider/guider.component.ts index 781040643..ebd3023be 100644 --- a/desktop/src/app/guider/guider.component.ts +++ b/desktop/src/app/guider/guider.component.ts @@ -38,10 +38,11 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { phdDitherPixels = 5 phdDitherRAOnly = false - phdSettlePixels = 1.5 - phdSettleTime = 60 - phdSettleTimeout = 90 + phdSettleAmount = 1.5 + phdSettleTime = 10 + phdSettleTimeout = 30 readonly phdGuideHistory: HistoryStep[] = [] + private phdDurationScale = 1.0 phdPixelScale = 1.0 phdRmsRA = 0.0 @@ -69,13 +70,14 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { } readonly phdChartData: ChartData = { + labels: Array.from({ length: 100 }, (_, i) => `${i}`), datasets: [ // RA. { type: 'line', fill: false, - borderColor: 'red', - borderWidth: 0.5, + borderColor: '#F44336', + borderWidth: 2, data: [], pointRadius: 0, pointHitRadius: 0, @@ -84,8 +86,8 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { { type: 'line', fill: false, - borderColor: 'blue', - borderWidth: 0.5, + borderColor: '#03A9F4', + borderWidth: 2, data: [], pointRadius: 0, pointHitRadius: 0, @@ -93,13 +95,13 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { // RA. { type: 'bar', - backgroundColor: 'green', + backgroundColor: '#F4433630', data: [], }, // DEC. { type: 'bar', - backgroundColor: 'green', + backgroundColor: '#03A9F430', data: [], }, ] @@ -107,8 +109,6 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { readonly phdChartOptions: ChartOptions = { responsive: true, - aspectRatio: 1.8, - maintainAspectRatio: false, plugins: { legend: { display: false, @@ -116,12 +116,24 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { tooltip: { displayColors: false, intersect: false, + filter: (item) => { + return Math.abs(item.parsed.y) - 0.01 > 0.0 + }, callbacks: { title: () => { return '' }, label: (context) => { - return context.parsed.y.toFixed(2) + console.log(context) + const barType = context.dataset.type === 'bar' + const raType = context.datasetIndex === 0 || context.datasetIndex === 2 + const scale = barType ? this.phdDurationScale : 1.0 + const y = context.parsed.y * scale + const prefix = raType ? 'RA: ' : 'DEC: ' + const barSuffix = ' ms' + const lineSuffix = this.yAxisUnit === 'ARCSEC' ? '"' : 'px' + const formattedY = prefix + (barType ? y.toFixed(0) + barSuffix : y.toFixed(2) + lineSuffix) + return formattedY } } }, @@ -156,8 +168,8 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { y: { stacked: true, beginAtZero: false, - suggestedMin: -16, - suggestedMax: 16, + min: -16, + max: 16, ticks: { autoSkip: false, count: 7, @@ -178,7 +190,6 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { }, x: { stacked: true, - type: 'linear', min: 0, max: 100, border: { @@ -279,6 +290,10 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { } async ngAfterViewInit() { + this.phdSettleAmount = this.preference.get('guiding.settleAmount', 1.5) + this.phdSettleTime = this.preference.get('guiding.settleTime', 10) + this.phdSettleTimeout = this.preference.get('guiding.settleTimeout', 30) + this.guideOutputs = await this.api.guideOutputs() const status = await this.api.guidingStatus() @@ -329,26 +344,28 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { maxDuration = Math.max(maxDuration, Math.abs(step.guideStep!.decDuration)) } - const durationScale = maxDuration / 16.0 + this.phdDurationScale = maxDuration / 16.0 if (this.plotMode === 'RA/DEC') { this.phdChartData.datasets[0].data = guideSteps - .map(e => [e.id - startId, e.guideStep!.raDistance * scale]) + .map(e => [e.id - startId, -e.guideStep!.raDistance * scale]) this.phdChartData.datasets[1].data = guideSteps .map(e => [e.id - startId, e.guideStep!.decDistance * scale]) } else { this.phdChartData.datasets[0].data = guideSteps - .map(e => [e.id - startId, e.guideStep!.dx * scale]) + .map(e => [e.id - startId, -e.guideStep!.dx * scale]) this.phdChartData.datasets[1].data = guideSteps .map(e => [e.id - startId, e.guideStep!.dy * scale]) } + const durationScale = (direction?: GuideDirection) => { + return !direction || direction === 'NORTH' || direction === 'WEST' ? this.phdDurationScale : -this.phdDurationScale + } + this.phdChartData.datasets[2].data = this.phdGuideHistory - // .map(e => (e.guideStep?.raDuration ?? 0) / durationScale) - .map(e => 12) + .map(e => (e.guideStep?.raDuration ?? 0) / durationScale(e.guideStep?.raDirection)) this.phdChartData.datasets[3].data = this.phdGuideHistory - // .map(e => (e.guideStep?.decDuration ?? 0) / durationScale) - .map(e => 8) + .map(e => (e.guideStep?.decDuration ?? 0) / durationScale(e.guideStep?.decDirection)) this.phdChart?.refresh() } @@ -398,7 +415,7 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { this.api.guideOutputPulse(this.guideOutput!, 'EAST', 0) } - connectPHD2() { + guidingConnect() { if (this.phdConnected) { this.api.guidingDisconnect() } else { @@ -411,7 +428,15 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { await this.api.guidingStart(event.shiftKey) } + async guidingSettleChanged() { + await this.api.guidingSettle(this.phdSettleAmount, this.phdSettleTime, this.phdSettleTimeout) + this.preference.set('guiding.settleAmount', this.phdSettleAmount) + this.preference.set('guiding.settleTime', this.phdSettleTime) + this.preference.set('guiding.settleTimeout', this.phdSettleTimeout) + } + guidingClearHistory() { + this.phdGuideHistory.length = 0 this.api.guidingClearHistory() } diff --git a/desktop/src/app/home/home.component.html b/desktop/src/app/home/home.component.html index ca7bb3055..a6c96703d 100644 --- a/desktop/src/app/home/home.component.html +++ b/desktop/src/app/home/home.component.html @@ -15,8 +15,8 @@
- - + +
@@ -78,7 +78,8 @@
- +
Alignment
diff --git a/desktop/src/app/home/home.component.ts b/desktop/src/app/home/home.component.ts index 1ecb054a9..75f547a74 100644 --- a/desktop/src/app/home/home.component.ts +++ b/desktop/src/app/home/home.component.ts @@ -218,6 +218,9 @@ export class HomeComponent implements AfterContentInit, OnDestroy { case 'FRAMING': this.browserWindow.openFraming(undefined, { bringToFront: true }) break + case 'ALIGNMENT': + this.browserWindow.openAlignment({ bringToFront: true }) + break case 'INDI': this.browserWindow.openINDI(undefined, { bringToFront: true }) break diff --git a/desktop/src/app/image/image.component.html b/desktop/src/app/image/image.component.html index cf295723e..d84511e59 100644 --- a/desktop/src/app/image/image.component.html +++ b/desktop/src/app/image/image.component.html @@ -1,5 +1,6 @@
- + @@ -51,7 +52,7 @@
- + @@ -110,9 +111,9 @@
- - - + + +
@@ -212,17 +213,17 @@
+ [text]="true" sevirity="info" /> + [text]="true" sevirity="success" /> + [text]="true" sevirity="success" /> + [text]="true" />
- + @@ -257,8 +258,8 @@ - - + + @@ -296,7 +297,7 @@ - + diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 6c7ca177f..b7876f4dd 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -376,6 +376,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } private loadImageFromParams(params: ImageParams) { + console.info('loading image from params: %s', params) + this.imageParams = params if (params.source === 'FRAMING') { diff --git a/desktop/src/app/indi/indi.component.html b/desktop/src/app/indi/indi.component.html index 4f0683cd9..8d27baf9c 100644 --- a/desktop/src/app/indi/indi.component.html +++ b/desktop/src/app/indi/indi.component.html @@ -9,8 +9,7 @@
- +
diff --git a/desktop/src/app/indi/property/indi-property.component.html b/desktop/src/app/indi/property/indi-property.component.html index 3ac4d7218..d179d22e3 100644 --- a/desktop/src/app/indi/property/indi-property.component.html +++ b/desktop/src/app/indi/property/indi-property.component.html @@ -6,8 +6,7 @@
+ (onClick)="sendSwitch(item)" icon="pi" [severity]="item.value ? 'success' : 'danger'">
@@ -17,8 +16,7 @@
+ (onClick)="sendSwitch(item)" icon="pi" [severity]="item.value ? 'success' : 'danger'">
diff --git a/desktop/src/app/mount/mount.component.html b/desktop/src/app/mount/mount.component.html index dc170d2ed..de4850af3 100644 --- a/desktop/src/app/mount/mount.component.html +++ b/desktop/src/app/mount/mount.component.html @@ -9,9 +9,9 @@
+ size="small" severity="danger" pTooltip="Disconnect" tooltipPosition="bottom" /> + size="small" severity="info" pTooltip="Connect" tooltipPosition="bottom" />
@@ -105,8 +105,8 @@
- +
@@ -140,8 +140,7 @@ icon="mdi mdi-arrow-left-thick" class="p-button-text">
- +
\ No newline at end of file diff --git a/desktop/src/shared/services/api.service.ts b/desktop/src/shared/services/api.service.ts index ffd84f7a1..7772811ad 100644 --- a/desktop/src/shared/services/api.service.ts +++ b/desktop/src/shared/services/api.service.ts @@ -64,8 +64,8 @@ export class ApiService { return this.http.put(`cameras/${camera.name}/temperature/setpoint?temperature=${temperature}`) } - cameraStartCapture(camera: Camera, value: CameraStartCapture) { - return this.http.put(`cameras/${camera.name}/capture/start`, value) + cameraStartCapture(camera: Camera, data: CameraStartCapture) { + return this.http.put(`cameras/${camera.name}/capture/start`, data) } cameraAbortCapture(camera: Camera) { @@ -289,11 +289,16 @@ export class ApiService { return this.http.put(`guiding/start?${query}`) } - guidingDither(pixels: number, raOnly: boolean = false) { - const query = this.http.query({ pixels, raOnly }) + guidingDither(amount: number, raOnly: boolean = false) { + const query = this.http.query({ amount, raOnly }) return this.http.put(`guiding/dither?${query}`) } + guidingSettle(amount: number, time: number, timeout: number) { + const query = this.http.query({ amount, time, timeout }) + return this.http.put(`guiding/settle?${query}`) + } + guidingStop() { return this.http.put(`guiding/stop`) } @@ -506,4 +511,16 @@ export class ApiService { const query = this.http.query({ rightAscension, declination, width, height, fov, rotation, hipsSurvey: hipsSurvey.type }) return this.http.put(`framing?${query}`) } + + // DARV + + darvStart(camera: Camera, guideOutput: GuideOutput, + exposureInSeconds: number, initialPauseInSeconds: number, direction: GuideDirection, reversed: boolean = false) { + const data = { exposureInSeconds, initialPauseInSeconds, direction, reversed } + return this.http.put(`polar-alignment/darv/${camera.name}/${guideOutput.name}/start`, data) + } + + darvStop(camera: Camera, guideOutput: GuideOutput) { + return this.http.put(`polar-alignment/darv/${camera.name}/${guideOutput.name}/stop`) + } } diff --git a/desktop/src/shared/services/browser-window.service.ts b/desktop/src/shared/services/browser-window.service.ts index 625980a50..8bd407bee 100644 --- a/desktop/src/shared/services/browser-window.service.ts +++ b/desktop/src/shared/services/browser-window.service.ts @@ -97,6 +97,14 @@ export class BrowserWindowService { }) } + openAlignment(options: OpenWindowOptions = {}) { + this.openWindow({ + ...options, + id: 'alignment', path: 'alignment', icon: options.icon || 'star', + width: options.width || 470, height: options.height || 280, + }) + } + openAbout() { this.openWindow({ id: 'about', path: 'about', icon: 'about', width: 470, height: 210, bringToFront: true }) } diff --git a/desktop/src/shared/types.ts b/desktop/src/shared/types.ts index 0ef146c99..4b270ac96 100644 --- a/desktop/src/shared/types.ts +++ b/desktop/src/shared/types.ts @@ -443,6 +443,22 @@ export interface Satellite { groups: SatelliteGroupType[] } +export interface DARVPolarAlignmentEvent { + camera: Camera + guideOutput: GuideOutput + remainingTime: number + progress: number +} + +export interface DARVPolarAlignmentInitialPauseElapsed extends DARVPolarAlignmentEvent { + pauseTime: number +} + +export interface DARVPolarAlignmentGuidePulseElapsed extends DARVPolarAlignmentEvent { + forward: boolean + direction: GuideDirection +} + export enum ExposureTimeUnit { MINUTE = 'm', SECOND = 's', @@ -639,6 +655,9 @@ export const INDI_EVENT_TYPES = [ // Guider. 'GUIDER_CONNECTED', 'GUIDER_DISCONNECTED', 'GUIDER_UPDATED', 'GUIDER_STEPPED', 'GUIDER_MESSAGE_RECEIVED', + // Polar Alignment. + 'DARV_POLAR_ALIGNMENT_STARTED', 'DARV_POLAR_ALIGNMENT_FINISHED', + 'DARV_POLAR_ALIGNMENT_INITIAL_PAUSE_ELAPSED', 'DARV_POLAR_ALIGNMENT_GUIDE_PULSE_ELAPSED', ] as const export type INDIEventType = (typeof INDI_EVENT_TYPES)[number] @@ -703,6 +722,15 @@ export type GuideDirection = 'NORTH' | // DEC+ 'WEST' | // RA+ 'EAST' // RA- +export function reverseGuideDirection(direction: GuideDirection): GuideDirection { + switch (direction) { + case 'NORTH': return 'SOUTH' + case 'SOUTH': return 'NORTH' + case 'WEST': return 'EAST' + case 'EAST': return 'WEST' + } +} + export const SATELLITE_GROUPS = [ 'LAST_30_DAYS', 'STATIONS', 'VISUAL', 'ACTIVE', 'ANALYST', 'COSMOS_1408_DEBRIS', @@ -737,3 +765,5 @@ export const GUIDE_STATES = [ ] as const export type GuideState = (typeof GUIDE_STATES)[number] + +export type Hemisphere = 'NORTHERN' | 'SOUTHERN' diff --git a/nebulosa-guiding-phd2/build.gradle.kts b/nebulosa-guiding-phd2/build.gradle.kts index db90ed18b..a2e9a4713 100644 --- a/nebulosa-guiding-phd2/build.gradle.kts +++ b/nebulosa-guiding-phd2/build.gradle.kts @@ -8,7 +8,7 @@ dependencies { api(project(":nebulosa-common")) api(project(":nebulosa-guiding")) api(project(":nebulosa-phd2-client")) - implementation(libs.jackson) + api(project(":nebulosa-json")) implementation(project(":nebulosa-log")) testImplementation(project(":nebulosa-test")) } diff --git a/nebulosa-guiding-phd2/src/main/kotlin/nebulosa/guiding/phd2/PHD2Guider.kt b/nebulosa-guiding-phd2/src/main/kotlin/nebulosa/guiding/phd2/PHD2Guider.kt index 8dcad7631..c677082f1 100644 --- a/nebulosa-guiding-phd2/src/main/kotlin/nebulosa/guiding/phd2/PHD2Guider.kt +++ b/nebulosa-guiding-phd2/src/main/kotlin/nebulosa/guiding/phd2/PHD2Guider.kt @@ -2,7 +2,6 @@ package nebulosa.guiding.phd2 import nebulosa.common.concurrency.CountUpDownLatch import nebulosa.guiding.* -import nebulosa.io.Base64OutputStream import nebulosa.log.loggerFor import nebulosa.math.arcsec import nebulosa.math.toArcsec @@ -10,11 +9,9 @@ import nebulosa.phd2.client.PHD2Client import nebulosa.phd2.client.PHD2EventListener import nebulosa.phd2.client.commands.* import nebulosa.phd2.client.events.* -import java.io.Closeable -import java.time.Duration -import kotlin.time.toKotlinDuration +import java.util.concurrent.TimeUnit -class PHD2Guider(private val client: PHD2Client) : Guider, PHD2EventListener, Closeable { +class PHD2Guider(private val client: PHD2Client) : Guider, PHD2EventListener { private val dither = DoubleArray(2) private val settling = CountUpDownLatch() @@ -43,9 +40,9 @@ class PHD2Guider(private val client: PHD2Client) : Guider, PHD2EventListener, Cl client.registerListener(this) } - override var settlePixels = 1.5 - override var settleTime = Duration.ofSeconds(10)!! - override var settleTimeout = Duration.ofSeconds(30)!! + override var settleAmount = Guider.DEFAULT_SETTLE_AMOUNT + override var settleTime = Guider.DEFAULT_SETTLE_TIME + override var settleTimeout = Guider.DEFAULT_SETTLE_TIMEOUT override fun registerGuiderListener(listener: GuiderListener) { listeners.add(listener) @@ -109,7 +106,7 @@ class PHD2Guider(private val client: PHD2Client) : Guider, PHD2EventListener, Cl waitForGuidingStarted() if (waitForSettle) { - waitForSettling() + waitForSettle() } return @@ -153,17 +150,17 @@ class PHD2Guider(private val client: PHD2Client) : Guider, PHD2EventListener, Cl Thread.sleep(100) } - override fun dither(pixels: Double, raOnly: Boolean) { + override fun dither(amount: Double, raOnly: Boolean) { val state = client.sendCommandSync(GetAppState) if (state == GuideState.GUIDING) { - waitForSettling() + waitForSettle() - val dither = Dither(pixels, raOnly, settlePixels, settleTime.toKotlinDuration(), settleTimeout.toKotlinDuration()) + val dither = Dither(amount, raOnly, settleAmount, settleTime, settleTimeout) client.sendCommandSync(dither) settling.countUp() - waitForSettling() + waitForSettle() } } @@ -201,8 +198,8 @@ class PHD2Guider(private val client: PHD2Client) : Guider, PHD2EventListener, Cl private fun startGuide(forceCalibration: Boolean): Boolean { return try { - waitForSettling() - val command = Guide(settlePixels, settleTime.toKotlinDuration(), settleTimeout.toKotlinDuration(), forceCalibration) + waitForSettle() + val command = Guide(settleAmount, settleTime, settleTimeout, forceCalibration) client.sendCommandSync(command) refreshShiftLockParams() true @@ -232,11 +229,14 @@ class PHD2Guider(private val client: PHD2Client) : Guider, PHD2EventListener, Cl } } - override fun waitForSettling() { + override fun waitForSettle() { try { - settling.await(settleTimeout) + settling.await(settleTimeout.inWholeNanoseconds, TimeUnit.NANOSECONDS) } catch (e: InterruptedException) { LOG.warn("PHD2 did not send SettleDone message in expected time") + } catch (e: Throwable) { + LOG.warn("an error occurrs while waiting for settle done", e) + } finally { settling.reset() } } @@ -372,6 +372,5 @@ class PHD2Guider(private val client: PHD2Client) : Guider, PHD2EventListener, Cl companion object { @JvmStatic private val LOG = loggerFor() - @JvmStatic private val STAR_IMAGE_OUTPUT_STREAM = Base64OutputStream(32 * 1024) } } diff --git a/nebulosa-guiding/src/main/kotlin/nebulosa/guiding/Guider.kt b/nebulosa-guiding/src/main/kotlin/nebulosa/guiding/Guider.kt index 5d91fa6ce..2f9db6495 100644 --- a/nebulosa-guiding/src/main/kotlin/nebulosa/guiding/Guider.kt +++ b/nebulosa-guiding/src/main/kotlin/nebulosa/guiding/Guider.kt @@ -1,8 +1,10 @@ package nebulosa.guiding -import java.time.Duration +import java.io.Closeable +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds -interface Guider { +interface Guider : Closeable { val state: GuideState @@ -10,7 +12,7 @@ interface Guider { val isSettling: Boolean - var settlePixels: Double + var settleAmount: Double var settleTime: Duration @@ -30,7 +32,14 @@ interface Guider { fun clearCalibration() - fun dither(pixels: Double, raOnly: Boolean = false) + fun dither(amount: Double, raOnly: Boolean = false) - fun waitForSettling() + fun waitForSettle() + + companion object { + + @JvmStatic val DEFAULT_SETTLE_AMOUNT = 1.5 + @JvmStatic val DEFAULT_SETTLE_TIME = 10.seconds + @JvmStatic val DEFAULT_SETTLE_TIMEOUT = 30.seconds + } } diff --git a/nebulosa-guiding/src/main/kotlin/nebulosa/guiding/GuiderListener.kt b/nebulosa-guiding/src/main/kotlin/nebulosa/guiding/GuiderListener.kt index cfeae834b..f58744e9d 100644 --- a/nebulosa-guiding/src/main/kotlin/nebulosa/guiding/GuiderListener.kt +++ b/nebulosa-guiding/src/main/kotlin/nebulosa/guiding/GuiderListener.kt @@ -2,11 +2,11 @@ package nebulosa.guiding interface GuiderListener { - fun onStateChanged(state: GuideState, pixelScale: Double) + fun onStateChanged(state: GuideState, pixelScale: Double) = Unit - fun onGuideStepped(guideStar: GuideStar) + fun onGuideStepped(guideStar: GuideStar) = Unit - fun onDithered(dx: Double, dy: Double) + fun onDithered(dx: Double, dy: Double) = Unit - fun onMessageReceived(message: String) + fun onMessageReceived(message: String) = Unit } diff --git a/nebulosa-json/build.gradle.kts b/nebulosa-json/build.gradle.kts index b44e829f4..7ab28945d 100644 --- a/nebulosa-json/build.gradle.kts +++ b/nebulosa-json/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } dependencies { - api(libs.jackson) + api(libs.bundles.jackson) testImplementation(project(":nebulosa-test")) } diff --git a/nebulosa-json/src/main/kotlin/nebulosa/json/Module.kt b/nebulosa-json/src/main/kotlin/nebulosa/json/Module.kt new file mode 100644 index 000000000..464d7eac5 --- /dev/null +++ b/nebulosa-json/src/main/kotlin/nebulosa/json/Module.kt @@ -0,0 +1,40 @@ +package nebulosa.json + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.databind.ser.std.StdSerializer + +data class ToJsonSerializer(private val serializer: ToJson) : StdSerializer(serializer.type) { + + override fun serialize(value: T?, gen: JsonGenerator, provider: SerializerProvider) { + if (value != null) serializer.serialize(value, gen, provider) + else gen.writeNull() + } +} + +data class FromJsonDeserializer(private val deserializer: FromJson) : StdDeserializer(deserializer.type) { + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): T? { + return deserializer.deserialize(p, ctxt) + } +} + +@Suppress("NOTHING_TO_INLINE") +inline fun SimpleModule.addSerializer(serializer: ToJson) = apply { + addSerializer(serializer.type, ToJsonSerializer(serializer)) +} + +@Suppress("NOTHING_TO_INLINE") +inline fun SimpleModule.addDeserializer(deserializer: FromJson) = apply { + addDeserializer(deserializer.type, FromJsonDeserializer(deserializer)) +} + +@Suppress("NOTHING_TO_INLINE") +inline fun SimpleModule.addConverter(converter: T) where T : FromJson<*>, T : ToJson<*> = apply { + addSerializer(converter) + addDeserializer(converter) +} diff --git a/nebulosa-json/src/main/kotlin/nebulosa/json/SimpleJsonModule.kt b/nebulosa-json/src/main/kotlin/nebulosa/json/SimpleJsonModule.kt deleted file mode 100644 index 9ea75d84e..000000000 --- a/nebulosa-json/src/main/kotlin/nebulosa/json/SimpleJsonModule.kt +++ /dev/null @@ -1,40 +0,0 @@ -package nebulosa.json - -import com.fasterxml.jackson.core.JsonGenerator -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.SerializerProvider -import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.fasterxml.jackson.databind.module.SimpleModule -import com.fasterxml.jackson.databind.ser.std.StdSerializer - -class SimpleJsonModule : SimpleModule() { - - fun addSerializer(serializer: ToJson) = apply { - addSerializer(serializer.type, ToJsonSerializer(serializer)) - } - - fun addDeserializer(deserializer: FromJson) = apply { - addDeserializer(deserializer.type, FromJsonDeserializer(deserializer)) - } - - fun addConverter(converter: T) where T : FromJson<*>, T : ToJson<*> = apply { - addSerializer(converter) - addDeserializer(converter) - } - - private data class ToJsonSerializer(private val serializer: ToJson) : StdSerializer(serializer.type) { - - override fun serialize(value: T?, gen: JsonGenerator, provider: SerializerProvider) { - if (value != null) serializer.serialize(value, gen, provider) - else gen.writeNull() - } - } - - private data class FromJsonDeserializer(private val deserializer: FromJson) : StdDeserializer(deserializer.type) { - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): T? { - return deserializer.deserialize(p, ctxt) - } - } -} diff --git a/nebulosa-phd2-client/build.gradle.kts b/nebulosa-phd2-client/build.gradle.kts index 696f631a1..4e837f030 100644 --- a/nebulosa-phd2-client/build.gradle.kts +++ b/nebulosa-phd2-client/build.gradle.kts @@ -7,7 +7,6 @@ dependencies { api(project(":nebulosa-netty")) api(project(":nebulosa-json")) api(project(":nebulosa-guiding")) - implementation(libs.jackson) implementation(project(":nebulosa-log")) testImplementation(project(":nebulosa-test")) } diff --git a/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/PHD2Client.kt b/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/PHD2Client.kt index a5de1df04..cf4e34146 100644 --- a/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/PHD2Client.kt +++ b/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/PHD2Client.kt @@ -3,10 +3,12 @@ package nebulosa.phd2.client import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.MapperFeature -import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.module.kotlin.jsonMapper import io.netty.channel.ChannelInitializer import io.netty.channel.socket.SocketChannel -import nebulosa.json.SimpleJsonModule +import nebulosa.json.addConverter +import nebulosa.json.addDeserializer import nebulosa.json.converters.PathConverter import nebulosa.log.loggerFor import nebulosa.netty.NettyClient @@ -68,18 +70,18 @@ class PHD2Client : NettyClient() { @JvmStatic private val LOG = loggerFor() - private val MODULE = SimpleJsonModule() + private val MODULE = SimpleModule() init { MODULE.addDeserializer(PathConverter) MODULE.addConverter(GuideStateConverter) } - private val JSON_MAPPER = JsonMapper.builder() - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) - .serializationInclusion(JsonInclude.Include.NON_NULL) - .addModule(MODULE) - .build() + private val JSON_MAPPER = jsonMapper { + disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + serializationInclusion(JsonInclude.Include.NON_NULL) + addModule(MODULE) + } } } diff --git a/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/commands/Dither.kt b/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/commands/Dither.kt index b8ea172c1..b712c004f 100644 --- a/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/commands/Dither.kt +++ b/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/commands/Dither.kt @@ -1,14 +1,14 @@ package nebulosa.phd2.client.commands +import nebulosa.guiding.Guider import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds data class Dither( val amount: Double, val raOnly: Boolean = false, - val settlePixels: Double = 1.5, - val settleTime: Duration = 10.seconds, - val settleTimeout: Duration = 60.seconds, + val settleAmount: Double = Guider.DEFAULT_SETTLE_AMOUNT, + val settleTime: Duration = Guider.DEFAULT_SETTLE_TIME, + val settleTimeout: Duration = Guider.DEFAULT_SETTLE_TIMEOUT, ) : PHD2Command { override val methodName = "dither" @@ -16,7 +16,7 @@ data class Dither( override val params = mapOf( "amount" to amount, "raOnly" to raOnly, "settle" to mapOf( - "pixels" to settlePixels, "time" to settleTime.inWholeSeconds, + "pixels" to settleAmount, "time" to settleTime.inWholeSeconds, "timeout" to settleTimeout.inWholeSeconds, ) ) diff --git a/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/commands/Guide.kt b/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/commands/Guide.kt index 48e27795d..fa654667e 100644 --- a/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/commands/Guide.kt +++ b/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/commands/Guide.kt @@ -1,12 +1,12 @@ package nebulosa.phd2.client.commands +import nebulosa.guiding.Guider import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds data class Guide( - val settlePixels: Double = 1.5, - val settleTime: Duration = 10.seconds, - val settleTimeout: Duration = 60.seconds, + val settleAmount: Double = Guider.DEFAULT_SETTLE_AMOUNT, + val settleTime: Duration = Guider.DEFAULT_SETTLE_TIME, + val settleTimeout: Duration = Guider.DEFAULT_SETTLE_TIMEOUT, val recalibrate: Boolean = false, val x: Int = 0, val y: Int = 0, @@ -20,7 +20,7 @@ data class Guide( "recalibrate" to recalibrate, "roi" to if (width > 0 && height > 0) listOf(x, y, width, height) else null, "settle" to mapOf( - "pixels" to settlePixels, "time" to settleTime.inWholeSeconds, + "pixels" to settleAmount, "time" to settleTime.inWholeSeconds, "timeout" to settleTimeout.inWholeSeconds, ) ) diff --git a/nebulosa-phd2-client/src/test/kotlin/PHD2ClientTest.kt b/nebulosa-phd2-client/src/test/kotlin/PHD2ClientTest.kt index c9a1ddb8e..1095e557b 100644 --- a/nebulosa-phd2-client/src/test/kotlin/PHD2ClientTest.kt +++ b/nebulosa-phd2-client/src/test/kotlin/PHD2ClientTest.kt @@ -3,27 +3,20 @@ import io.kotest.core.spec.style.StringSpec import kotlinx.coroutines.delay import nebulosa.phd2.client.PHD2Client import nebulosa.phd2.client.PHD2EventListener -import nebulosa.phd2.client.commands.GetStarImage import nebulosa.phd2.client.commands.PHD2Command import nebulosa.phd2.client.events.PHD2Event -import java.io.File -import javax.imageio.ImageIO @EnabledIf(NonGitHubOnlyCondition::class) class PHD2ClientTest : StringSpec(), PHD2EventListener { init { "start" { - val client = PHD2Client("localhost") + val client = PHD2Client() client.registerListener(this@PHD2ClientTest) - client.run() + client.open("localhost", PHD2Client.DEFAULT_PORT) delay(1000) - val image = client.sendCommandSync(GetStarImage(64)) - val decodedImage = image.decodeImage() - ImageIO.write(decodedImage, "PNG", File("/home/tiagohm/Área de Trabalho/NOTAS.png")) - client.close() } } diff --git a/nebulosa-retrofit/build.gradle.kts b/nebulosa-retrofit/build.gradle.kts index afe6c9358..d68a28ef0 100644 --- a/nebulosa-retrofit/build.gradle.kts +++ b/nebulosa-retrofit/build.gradle.kts @@ -4,9 +4,9 @@ plugins { } dependencies { + api(project(":nebulosa-json")) api(libs.retrofit) api(libs.retrofit.jackson) - api(libs.jackson) api(libs.okhttp) api(libs.okhttp.logging) compileOnly(libs.csv) diff --git a/settings.gradle.kts b/settings.gradle.kts index 93f8bfbb4..87d91809d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,8 +16,9 @@ dependencyResolutionManagement { library("okhttp", "com.squareup.okhttp3:okhttp:5.0.0-alpha.11") library("okhttp-logging", "com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.11") library("fits", "gov.nasa.gsfc.heasarc:nom-tam-fits:1.18.1") - library("jackson", "com.fasterxml.jackson.core:jackson-databind:2.15.3") + library("jackson-core", "com.fasterxml.jackson.core:jackson-databind:2.15.3") library("jackson-jsr310", "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.3") + library("jackson-kt", "com.fasterxml.jackson.module:jackson-module-kotlin:2.15.3") library("retrofit", "com.squareup.retrofit2:retrofit:2.9.0") library("retrofit-jackson", "com.squareup.retrofit2:converter-jackson:2.9.0") library("rx", "io.reactivex.rxjava3:rxjava:3.1.8") @@ -39,6 +40,7 @@ dependencyResolutionManagement { library("kotest-runner-junit5", "io.kotest:kotest-runner-junit5:5.7.2") bundle("kotest", listOf("kotest-assertions-core", "kotest-runner-junit5")) bundle("netty", listOf("netty-transport", "netty-codec")) + bundle("jackson", listOf("jackson-core", "jackson-jsr310", "jackson-kt")) } } }