diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 88cfc8c51..05557ac63 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -24,8 +24,10 @@ dependencies { implementation(project(":nebulosa-log")) implementation(project(":nebulosa-lx200-protocol")) implementation(project(":nebulosa-nova")) + implementation(project(":nebulosa-pixinsight")) implementation(project(":nebulosa-sbd")) implementation(project(":nebulosa-simbad")) + implementation(project(":nebulosa-siril")) implementation(project(":nebulosa-stellarium-protocol")) implementation(project(":nebulosa-wcs")) implementation(project(":nebulosa-xisf")) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVTask.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVTask.kt index 65e4ea05c..73842019a 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVTask.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVTask.kt @@ -17,6 +17,7 @@ import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.CameraEvent import nebulosa.indi.device.camera.FrameType import nebulosa.indi.device.guide.GuideOutput +import nebulosa.indi.device.mount.Mount import nebulosa.log.loggerFor import java.nio.file.Files import java.time.Duration @@ -64,7 +65,7 @@ data class DARVTask( override fun execute(cancellationToken: CancellationToken) { LOG.info("DARV started. camera={}, guideOutput={}, request={}", camera, guideOutput, request) - camera.snoop(listOf(guideOutput)) + if (guideOutput is Mount) camera.snoop(camera.snoopedDevices.filter { it !is Mount } + guideOutput) val task = SplitTask(listOf(cameraCaptureTask, Task.of(delayTask, forwardGuidePulseTask, backwardGuidePulseTask)), executor) task.execute(cancellationToken) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStartRequest.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStartRequest.kt index acb655885..0377eb526 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStartRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStartRequest.kt @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties import jakarta.validation.Valid import jakarta.validation.constraints.NotNull import nebulosa.api.cameras.CameraStartCaptureRequest -import nebulosa.api.solver.PlateSolverOptions +import nebulosa.api.platesolver.PlateSolverRequest import nebulosa.guiding.GuideDirection import org.hibernate.validator.constraints.time.DurationMin import org.springframework.boot.convert.DurationUnit @@ -13,7 +13,7 @@ import java.time.temporal.ChronoUnit data class TPPAStartRequest( @JsonIgnoreProperties("camera", "focuser", "wheel") @JvmField val capture: CameraStartCaptureRequest = CameraStartCaptureRequest.EMPTY, - @field:NotNull @Valid @JvmField val plateSolver: PlateSolverOptions = PlateSolverOptions.EMPTY, + @field:NotNull @Valid @JvmField val plateSolver: PlateSolverRequest = PlateSolverRequest.EMPTY, @JvmField val startFromCurrentPosition: Boolean = true, @JvmField val compensateRefraction: Boolean = false, @JvmField val stopTrackingWhenDone: Boolean = true, diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt index e3ae706ff..b3d5ceeb1 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt @@ -21,7 +21,7 @@ import nebulosa.log.loggerFor import nebulosa.math.Angle import nebulosa.math.formatHMS import nebulosa.math.formatSignedDMS -import nebulosa.plate.solving.PlateSolver +import nebulosa.platesolver.PlateSolver import java.nio.file.Files import java.nio.file.Path import java.time.Duration @@ -84,7 +84,7 @@ data class TPPATask( captureEvent = event if (event.state == CameraCaptureState.EXPOSURE_FINISHED) { - savedImage = event.savePath!! + savedImage = event.savedPath!! } if (!finished.get()) { @@ -110,7 +110,7 @@ data class TPPATask( rightAscension = mount?.rightAscension ?: 0.0 declination = mount?.declination ?: 0.0 - camera.snoop(listOf(mount)) + camera.snoop(camera.snoopedDevices.filter { it !is Mount } + mount) cancellationToken.listenToPause(this) diff --git a/api/src/main/kotlin/nebulosa/api/atlas/SatelliteRepository.kt b/api/src/main/kotlin/nebulosa/api/atlas/SatelliteRepository.kt index 4b576a6ac..8ed07bd53 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/SatelliteRepository.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/SatelliteRepository.kt @@ -1,9 +1,7 @@ package nebulosa.api.atlas import io.objectbox.Box -import io.objectbox.query.QueryBuilder.StringOrder.CASE_INSENSITIVE import io.objectbox.query.QueryBuilder.StringOrder.CASE_SENSITIVE -import io.objectbox.query.QueryCondition import nebulosa.api.repositories.BoxRepository import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Component @@ -11,22 +9,10 @@ import org.springframework.stereotype.Component @Component class SatelliteRepository(@Qualifier("satelliteBox") override val box: Box) : BoxRepository() { - fun search(text: String? = null, groups: List = emptyList()): List { - val builder = box.query() - .also { if (!text.isNullOrBlank()) it.contains(SatelliteEntity_.name, text, CASE_INSENSITIVE) } + fun search(text: String? = null, groups: Iterable = emptyList()): List { + val groupCondition = or(groups.map { SatelliteEntity_.groups.containsElement(it.name, CASE_SENSITIVE) }) + val condition = and(if (text.isNullOrBlank()) null else SatelliteEntity_.name.containsInsensitive(text), groupCondition) - if (groups.isNotEmpty()) { - var condition: QueryCondition = SatelliteEntity_.groups.containsElement(groups[0].name, CASE_SENSITIVE) - - for (i in 1 until groups.size) { - condition = condition.or(SatelliteEntity_.groups.containsElement(groups[i].name, CASE_SENSITIVE)) - } - - builder.apply(condition) - } - - return builder - .build() - .use { it.findLazy() } + return (condition?.let(box::query) ?: box.query()).build().use { it.findLazy() } } } diff --git a/api/src/main/kotlin/nebulosa/api/atlas/SimbadEntityRepository.kt b/api/src/main/kotlin/nebulosa/api/atlas/SimbadEntityRepository.kt index 54040bec7..a91e14892 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/SimbadEntityRepository.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/SimbadEntityRepository.kt @@ -2,20 +2,17 @@ package nebulosa.api.atlas import io.objectbox.Box import io.objectbox.kotlin.equal -import io.objectbox.query.QueryBuilder.StringOrder.CASE_INSENSITIVE -import io.objectbox.query.QueryFilter import nebulosa.api.repositories.BoxRepository import nebulosa.math.Angle import nebulosa.math.toDegrees import nebulosa.nova.astrometry.Constellation import nebulosa.skycatalog.SkyObject -import nebulosa.skycatalog.SkyObjectInsideCoordinate import nebulosa.skycatalog.SkyObjectType import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Component @Component -class SimbadEntityRepository(@Qualifier("simbadEntityBox") override val box: Box) : BoxRepository() { +class SimbadEntityRepository(@Qualifier("simbadBox") override val box: Box) : BoxRepository() { fun find( name: String? = null, constellation: Constellation? = null, @@ -23,28 +20,20 @@ class SimbadEntityRepository(@Qualifier("simbadEntityBox") override val box: Box magnitudeMin: Double = SkyObject.MAGNITUDE_MIN, magnitudeMax: Double = SkyObject.MAGNITUDE_MAX, type: SkyObjectType? = null, ): List { - val useFilter = radius > 0.0 && radius.toDegrees > 0.1 - - return box.query() - .also { - if (magnitudeMin in SkyObject.MAGNITUDE_RANGE) it.greaterOrEqual(SimbadEntity_.magnitude, magnitudeMin) - if (magnitudeMax in SkyObject.MAGNITUDE_RANGE) it.lessOrEqual(SimbadEntity_.magnitude, magnitudeMax) - if (type != null) it.equal(SimbadEntity_.type, type.ordinal) - if (constellation != null) it.equal(SimbadEntity_.constellation, constellation.ordinal) - - if (!name.isNullOrBlank()) { - it.contains(SimbadEntity_.name, name, CASE_INSENSITIVE) - } - - if (useFilter) it.filter(object : QueryFilter { - private val filter = SkyObjectInsideCoordinate(rightAscension, declination, radius) - - override fun keep(entity: SimbadEntity) = filter.test(entity) - }) - - it.order(SimbadEntity_.magnitude) - } - .build() - .use { if (useFilter) it.find() else it.find(0, 5000) } + val useFilter = radius > 0.0 && radius.toDegrees in 0.1..90.0 + + val condition = and( + if (magnitudeMin in SkyObject.MAGNITUDE_RANGE) SimbadEntity_.magnitude.greaterOrEqual(magnitudeMin) else null, + if (magnitudeMax in SkyObject.MAGNITUDE_RANGE) SimbadEntity_.magnitude.lessOrEqual(magnitudeMax) else null, + if (type != null) SimbadEntity_.type equal type.ordinal else null, + if (constellation != null) SimbadEntity_.constellation equal constellation.ordinal else null, + if (name.isNullOrBlank()) null else SimbadEntity_.name containsInsensitive name, + ) + + return with(condition?.let(box::query) ?: box.query()) { + if (useFilter) filter(SkyObjectInsideCoordinate(rightAscension, declination, radius)) + order(SimbadEntity_.magnitude) + build() + }.use { if (useFilter) it.find() else it.find(0, 5000) } } } diff --git a/nebulosa-skycatalog/src/main/kotlin/nebulosa/skycatalog/SkyObjectInsideCoordinate.kt b/api/src/main/kotlin/nebulosa/api/atlas/SkyObjectInsideCoordinate.kt similarity index 78% rename from nebulosa-skycatalog/src/main/kotlin/nebulosa/skycatalog/SkyObjectInsideCoordinate.kt rename to api/src/main/kotlin/nebulosa/api/atlas/SkyObjectInsideCoordinate.kt index b4f94da39..10b0857bb 100644 --- a/nebulosa-skycatalog/src/main/kotlin/nebulosa/skycatalog/SkyObjectInsideCoordinate.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/SkyObjectInsideCoordinate.kt @@ -1,5 +1,6 @@ -package nebulosa.skycatalog +package nebulosa.api.atlas +import io.objectbox.query.QueryFilter import nebulosa.math.Angle import nebulosa.math.cos import nebulosa.math.sin @@ -11,12 +12,12 @@ data class SkyObjectInsideCoordinate( private val rightAscension: Angle, private val declination: Angle, private val radius: Angle, -) : SkyObjectFilter { +) : QueryFilter { private val sinDEC = declination.sin private val cosDEC = declination.cos - override fun test(o: SkyObject): Boolean { + override fun keep(o: SimbadEntity): Boolean { return acos(sin(o.declinationJ2000) * sinDEC + cos(o.declinationJ2000) * cosDEC * cos(o.rightAscensionJ2000 - rightAscension)) <= radius } } diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusRequest.kt b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusRequest.kt index 5f1c5edd1..1ba8511da 100644 --- a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusRequest.kt @@ -2,7 +2,7 @@ package nebulosa.api.autofocus import nebulosa.api.cameras.CameraStartCaptureRequest import nebulosa.api.focusers.BacklashCompensation -import nebulosa.api.stardetection.StarDetectionOptions +import nebulosa.api.stardetector.StarDetectionRequest data class AutoFocusRequest( @JvmField val fittingMode: AutoFocusFittingMode = AutoFocusFittingMode.HYPERBOLIC, @@ -12,5 +12,5 @@ data class AutoFocusRequest( @JvmField val initialOffsetSteps: Int = 4, @JvmField val stepSize: Int = 50, @JvmField val totalNumberOfAttempts: Int = 1, - @JvmField val starDetector: StarDetectionOptions = StarDetectionOptions.EMPTY, + @JvmField val starDetector: StarDetectionRequest = StarDetectionRequest.EMPTY, ) diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusTask.kt b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusTask.kt index 249e5c51d..9f0995f60 100644 --- a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusTask.kt +++ b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusTask.kt @@ -19,8 +19,8 @@ import nebulosa.indi.device.camera.FrameType import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.focuser.FocuserEvent import nebulosa.log.loggerFor -import nebulosa.star.detection.ImageStar -import nebulosa.star.detection.StarDetector +import nebulosa.stardetector.StarDetector +import nebulosa.stardetector.StarPoint import java.nio.file.Files import java.nio.file.Path import java.time.Duration @@ -85,7 +85,7 @@ data class AutoFocusTask( var numberOfAttempts = 0 val maximumFocusPoints = request.capture.exposureAmount * request.initialOffsetSteps * 10 - // camera.snoop(listOf(focuser)) + camera.snoop(camera.snoopedDevices.filter { it !is Focuser } + focuser) while (!exited && !cancellationToken.isCancelled) { numberOfAttempts++ @@ -220,7 +220,7 @@ data class AutoFocusTask( if (event.state == CameraCaptureState.EXPOSURE_FINISHED) { sendEvent(AutoFocusState.EXPOSURED, event) sendEvent(AutoFocusState.ANALYSING) - val detectedStars = starDetection.detect(event.savePath!!) + val detectedStars = starDetection.detect(event.savedPath!!) starCount = detectedStars.size LOG.info("detected $starCount stars") starHFD = detectedStars.measureDetectedStars() @@ -401,7 +401,7 @@ data class AutoFocusTask( } @JvmStatic - private fun List.measureDetectedStars(): Double { + private fun List.measureDetectedStars(): Double { return if (isEmpty()) 0.0 else if (size == 1) this[0].hfd else if (size == 2) (this[0].hfd + this[1].hfd) / 2.0 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 b03a02883..65918224a 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.ser.std.StdSerializer import com.fasterxml.jackson.module.kotlin.kotlinModule import io.objectbox.BoxStore +import io.objectbox.kotlin.boxFor import nebulosa.api.atlas.SatelliteEntity import nebulosa.api.atlas.SimbadEntity import nebulosa.api.calibration.CalibrationFrameEntity @@ -170,16 +171,16 @@ class BeanConfiguration { .build()!! @Bean - fun calibrationFrameBox(boxStore: BoxStore) = boxStore.boxFor(CalibrationFrameEntity::class.java)!! + fun calibrationFrameBox(boxStore: BoxStore) = boxStore.boxFor() @Bean - fun preferenceBox(boxStore: BoxStore) = boxStore.boxFor(PreferenceEntity::class.java)!! + fun preferenceBox(boxStore: BoxStore) = boxStore.boxFor() @Bean - fun satelliteBox(boxStore: BoxStore) = boxStore.boxFor(SatelliteEntity::class.java)!! + fun satelliteBox(boxStore: BoxStore) = boxStore.boxFor() @Bean - fun simbadEntityBox(@Qualifier("simbadBoxStore") boxStore: BoxStore) = boxStore.boxFor(SimbadEntity::class.java)!! + fun simbadBox(@Qualifier("simbadBoxStore") boxStore: BoxStore) = boxStore.boxFor() @Bean fun webMvcConfigurer( diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/angle/DeclinationDeserializer.kt b/api/src/main/kotlin/nebulosa/api/beans/converters/angle/DeclinationDeserializer.kt index 01e170cfe..0ed5dcbd1 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/angle/DeclinationDeserializer.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/converters/angle/DeclinationDeserializer.kt @@ -1,3 +1,3 @@ package nebulosa.api.beans.converters.angle -class DeclinationDeserializer : AngleDeserializer(true) +class DeclinationDeserializer : AngleDeserializer(false) diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/angle/DegreesDeserializer.kt b/api/src/main/kotlin/nebulosa/api/beans/converters/angle/DegreesDeserializer.kt new file mode 100644 index 000000000..b97011eec --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/beans/converters/angle/DegreesDeserializer.kt @@ -0,0 +1,3 @@ +package nebulosa.api.beans.converters.angle + +class DegreesDeserializer : AngleDeserializer(false, defaultValue = 0.0) diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameProvider.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameProvider.kt new file mode 100644 index 000000000..0c3ed2a06 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameProvider.kt @@ -0,0 +1,20 @@ +package nebulosa.api.calibration + +interface CalibrationFrameProvider { + + fun findBestDarkFrames( + name: String, temperature: Double, width: Int, height: Int, + binX: Int, binY: Int = binX, exposureTimeInMicroseconds: Long = 0L, + gain: Double = 0.0, + ): List + + fun findBestFlatFrames( + name: String, width: Int, height: Int, + binX: Int, binY: Int = binX, filter: String? = null + ): List + + fun findBestBiasFrames( + name: String, width: Int, height: Int, + binX: Int, binY: Int = binX, gain: Double = 0.0, + ): List +} diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameRepository.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameRepository.kt index 10399bf92..056d8ac54 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameRepository.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameRepository.kt @@ -2,8 +2,6 @@ package nebulosa.api.calibration import io.objectbox.Box import io.objectbox.kotlin.equal -import io.objectbox.query.QueryBuilder.StringOrder.CASE_INSENSITIVE -import io.objectbox.query.QueryBuilder.StringOrder.CASE_SENSITIVE import nebulosa.api.repositories.BoxRepository import nebulosa.indi.device.camera.FrameType import org.springframework.beans.factory.annotation.Qualifier @@ -16,64 +14,60 @@ class CalibrationFrameRepository(@Qualifier("calibrationFrameBox") override val fun groups() = box.all.map { it.name }.distinct() fun findAll(name: String): List { - return box.query() - .equal(CalibrationFrameEntity_.name, name, CASE_SENSITIVE) - .build() - .use { it.find() } + return box.query(CalibrationFrameEntity_.name equal name) + .build().use { it.find() } } @Synchronized fun delete(name: String, path: String) { - return box.query() - .equal(CalibrationFrameEntity_.name, name, CASE_SENSITIVE) - .equal(CalibrationFrameEntity_.path, path, CASE_SENSITIVE) - .build() - .use { it.remove() } + val condition = and(CalibrationFrameEntity_.name equal name, CalibrationFrameEntity_.path equal path) + return box.query(condition).build().use { it.remove() } } fun darkFrames(name: String, width: Int, height: Int, bin: Int, exposureTime: Long, gain: Double): List { - return box.query() - .equal(CalibrationFrameEntity_.type, FrameType.DARK.ordinal) - .equal(CalibrationFrameEntity_.enabled, true) - .equal(CalibrationFrameEntity_.name, name, CASE_SENSITIVE) - .equal(CalibrationFrameEntity_.width, width) - .equal(CalibrationFrameEntity_.height, height) - .equal(CalibrationFrameEntity_.binX, bin) - .equal(CalibrationFrameEntity_.binY, bin) - .also { if (exposureTime > 0L) it.equal(CalibrationFrameEntity_.exposureTime, exposureTime) } - .also { if (gain > 0L) it.equal(CalibrationFrameEntity_.gain, gain, 1E-3) } - .build() - .use { it.find() } + val condition = and( + CalibrationFrameEntity_.type equal FrameType.DARK.ordinal, + CalibrationFrameEntity_.enabled.isTrue, + CalibrationFrameEntity_.name equal name, + CalibrationFrameEntity_.width equal width, + CalibrationFrameEntity_.height equal height, + CalibrationFrameEntity_.binX equal bin, + CalibrationFrameEntity_.binY equal bin, + if (exposureTime > 0L) CalibrationFrameEntity_.exposureTime equal exposureTime else null, + if (gain > 0L) CalibrationFrameEntity_.gain.equal(gain, 1E-3) else null, + ) + + return box.query(condition).build().use { it.find() } } fun biasFrames(name: String, width: Int, height: Int, bin: Int, gain: Double): List { - return box.query() - .equal(CalibrationFrameEntity_.type, FrameType.BIAS.ordinal) - .equal(CalibrationFrameEntity_.enabled, true) - .equal(CalibrationFrameEntity_.name, name, CASE_SENSITIVE) - .equal(CalibrationFrameEntity_.width, width) - .equal(CalibrationFrameEntity_.height, height) - .equal(CalibrationFrameEntity_.binX, bin) - .equal(CalibrationFrameEntity_.binY, bin) - .also { if (gain > 0L) it.equal(CalibrationFrameEntity_.gain, gain, 1E-3) } - .build() - .use { it.find() } + val condition = and( + CalibrationFrameEntity_.type equal FrameType.BIAS.ordinal, + CalibrationFrameEntity_.enabled.isTrue, + CalibrationFrameEntity_.name equal name, + CalibrationFrameEntity_.width equal width, + CalibrationFrameEntity_.height equal height, + CalibrationFrameEntity_.binX equal bin, + CalibrationFrameEntity_.binY equal bin, + if (gain > 0L) CalibrationFrameEntity_.gain.equal(gain, 1E-3) else null, + ) + + return box.query(condition).build().use { it.find() } } fun flatFrames(name: String, filter: String?, width: Int, height: Int, bin: Int): List { - return box.query() - .equal(CalibrationFrameEntity_.type, FrameType.FLAT.ordinal) - .equal(CalibrationFrameEntity_.enabled, true) - .equal(CalibrationFrameEntity_.name, name, CASE_SENSITIVE) - .also { - if (filter.isNullOrBlank()) it.isNull(CalibrationFrameEntity_.filter) - else it.equal(CalibrationFrameEntity_.filter, filter, CASE_INSENSITIVE) - } - .equal(CalibrationFrameEntity_.width, width) - .equal(CalibrationFrameEntity_.height, height) - .equal(CalibrationFrameEntity_.binX, bin) - .equal(CalibrationFrameEntity_.binY, bin) - .build() - .use { it.find() } + val condition = and( + CalibrationFrameEntity_.type equal FrameType.FLAT.ordinal, + CalibrationFrameEntity_.enabled.isTrue, + CalibrationFrameEntity_.name equal name, + CalibrationFrameEntity_.width equal width, + CalibrationFrameEntity_.height equal height, + CalibrationFrameEntity_.binX equal bin, + CalibrationFrameEntity_.binY equal bin, + if (filter.isNullOrBlank()) CalibrationFrameEntity_.filter.isNull + else CalibrationFrameEntity_.filter equalInsensitive filter, + ) + + return box.query(condition).build().use { it.find() } } } diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt index 8188e1b0d..012a7dbd7 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt @@ -26,31 +26,42 @@ import kotlin.math.roundToInt @Service class CalibrationFrameService( private val calibrationFrameRepository: CalibrationFrameRepository, -) { +) : CalibrationFrameProvider { fun calibrate(name: String, image: Image, createNew: Boolean = false): Image { return synchronized(image) { val darkFrame = findBestDarkFrames(name, image).firstOrNull() - val biasFrame = findBestBiasFrames(name, image).firstOrNull() + val biasFrame = if (darkFrame == null) findBestBiasFrames(name, image).firstOrNull() else null val flatFrame = findBestFlatFrames(name, image).firstOrNull() - if (darkFrame != null || biasFrame != null || flatFrame != null) { + val darkImage = darkFrame?.path?.fits()?.use(Image::open) + val biasImage = biasFrame?.path?.fits()?.use(Image::open) + var flatImage = flatFrame?.path?.fits()?.use(Image::open) + + if (darkImage != null || biasImage != null || flatImage != null) { var transformedImage = if (createNew) image.clone() else image - if (biasFrame != null) { - val calibrationImage = biasFrame.path!!.fits().use(Image::open) - transformedImage = transformedImage.transform(BiasSubtraction(calibrationImage)) + // If not using dark frames. + if (biasImage != null) { + // Subtract Master Bias from Flat Frames. + if (flatImage != null) { + flatImage = flatImage.transform(BiasSubtraction(biasImage)) + LOG.info("bias frame subtraction applied to flat frame. frame={}", biasFrame) + } + + // Subtract the Master Bias frame. + transformedImage = transformedImage.transform(BiasSubtraction(biasImage)) LOG.info("bias frame subtraction applied. frame={}", biasFrame) - } else { + } else if (darkFrame == null) { LOG.info( "no bias frames found. width={}, height={}, bin={}, gain={}", image.width, image.height, image.header.binX, image.header.gain ) } - if (darkFrame != null) { - val calibrationImage = darkFrame.path!!.fits().use(Image::open) - transformedImage = transformedImage.transform(DarkSubtraction(calibrationImage)) + // Subtract Master Dark frame. + if (darkImage != null) { + transformedImage = transformedImage.transform(DarkSubtraction(darkImage)) LOG.info("dark frame subtraction applied. frame={}", darkFrame) } else { LOG.info( @@ -59,9 +70,9 @@ class CalibrationFrameService( ) } - if (flatFrame != null) { - val calibrationImage = flatFrame.path!!.fits().use(Image::open) - transformedImage = transformedImage.transform(FlatCorrection(calibrationImage)) + // Divide the Dark-subtracted Light frame by the Master Flat frame to correct for variations in the optical path. + if (flatImage != null) { + transformedImage = transformedImage.transform(FlatCorrection(flatImage)) LOG.info("flat frame correction applied. frame={}", flatFrame) } else { LOG.info( @@ -146,13 +157,13 @@ class CalibrationFrameService( calibrationFrameRepository.delete(frame) } - // exposureTime, temperature, width, height, binX, binY, gain. - fun findBestDarkFrames(name: String, image: Image): List { - val header = image.header - val temperature = header.temperature - + override fun findBestDarkFrames( + name: String, temperature: Double, width: Int, height: Int, + binX: Int, binY: Int, exposureTimeInMicroseconds: Long, + gain: Double, + ): List { val frames = calibrationFrameRepository - .darkFrames(name, image.width, image.height, header.binX, header.exposureTimeInMicroseconds, header.gain) + .darkFrames(name, width, height, binX, exposureTimeInMicroseconds, gain) if (frames.isEmpty()) return emptyList() @@ -164,20 +175,46 @@ class CalibrationFrameService( return groupedFrames.firstEntry().value } - // filter, width, height, binX, binY. + fun findBestDarkFrames(name: String, image: Image): List { + val header = image.header + val temperature = header.temperature + val binX = header.binX + val exposureTime = header.exposureTimeInMicroseconds + + return findBestDarkFrames(name, temperature, image.width, image.height, binX, binX, exposureTime, header.gain) + } + + override fun findBestFlatFrames( + name: String, width: Int, height: Int, + binX: Int, binY: Int, filter: String? + ): List { + // TODO: Generate master from matched frames. (Subtract the master bias frame from each flat frame) + return calibrationFrameRepository + .flatFrames(name, filter, width, height, binX) + } + fun findBestFlatFrames(name: String, image: Image): List { - val filter = image.header.filter + val header = image.header + val filter = header.filter + val binX = header.binX + return findBestFlatFrames(name, image.width, image.height, binX, binX, filter) + } + + override fun findBestBiasFrames( + name: String, width: Int, height: Int, + binX: Int, binY: Int, gain: Double, + ): List { // TODO: Generate master from matched frames. return calibrationFrameRepository - .flatFrames(name, filter, image.width, image.height, image.header.binX) + .biasFrames(name, width, height, binX, gain) } - // width, height, binX, binY, gain. fun findBestBiasFrames(name: String, image: Image): List { - // TODO: Generate master from matched frames. - return calibrationFrameRepository - .biasFrames(name, image.width, image.height, image.header.binX, image.header.gain) + val header = image.header + val binX = header.binX + + return findBestBiasFrames(name, image.width, image.height, binX, binX, image.header.gain) } companion object { diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt index 805551724..7bcd3f6e2 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt @@ -1,12 +1,14 @@ package nebulosa.api.cameras +import com.fasterxml.jackson.annotation.JsonIgnore import nebulosa.api.messages.MessageEvent import nebulosa.indi.device.camera.Camera import java.nio.file.Path import java.time.Duration data class CameraCaptureEvent( - @JvmField val camera: Camera, + @JvmField @field:JsonIgnore val task: CameraCaptureTask, + @JvmField val camera: Camera = task.camera, @JvmField val state: CameraCaptureState = CameraCaptureState.IDLE, @JvmField val exposureAmount: Int = 0, @JvmField val exposureCount: Int = 0, @@ -16,15 +18,10 @@ data class CameraCaptureEvent( @JvmField val stepRemainingTime: Duration = Duration.ZERO, @JvmField val stepElapsedTime: Duration = Duration.ZERO, @JvmField val stepProgress: Double = 0.0, - @JvmField val savePath: Path? = null, + @JvmField val savedPath: Path? = null, + @JvmField val liveStackedPath: Path? = null, + @JvmField val capture: CameraStartCaptureRequest? = null, ) : MessageEvent { override val eventName = "CAMERA.CAPTURE_ELAPSED" - - companion object { - - @JvmStatic - fun exposureFinished(camera: Camera, savePath: Path) = - CameraCaptureEvent(camera, CameraCaptureState.EXPOSURE_FINISHED, savePath = savePath) - } } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt index 0c0a83e91..572dd53cc 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt @@ -2,6 +2,7 @@ package nebulosa.api.cameras import io.reactivex.rxjava3.functions.Consumer import nebulosa.api.beans.annotations.Subscriber +import nebulosa.api.calibration.CalibrationFrameService import nebulosa.api.messages.MessageService import nebulosa.guiding.Guider import nebulosa.indi.device.camera.Camera @@ -18,6 +19,7 @@ class CameraCaptureExecutor( private val messageService: MessageService, private val guider: Guider, private val threadPoolTaskExecutor: ThreadPoolTaskExecutor, + private val calibrationFrameService: CalibrationFrameService, ) : Consumer, CameraEventAware { private val jobs = ConcurrentHashMap.newKeySet(2) @@ -36,7 +38,7 @@ class CameraCaptureExecutor( check(camera.connected) { "${camera.name} Camera is not connected" } check(jobs.none { it.task.camera === camera }) { "${camera.name} Camera Capture is already in progress" } - val task = CameraCaptureTask(camera, request, guider, executor = threadPoolTaskExecutor) + val task = CameraCaptureTask(camera, request, guider, executor = threadPoolTaskExecutor, calibrationFrameProvider = calibrationFrameService) task.subscribe(this) with(CameraCaptureJob(task)) { @@ -46,6 +48,14 @@ class CameraCaptureExecutor( } } + fun pause(camera: Camera) { + jobs.find { it.task.camera === camera }?.pause() + } + + fun unpause(camera: Camera) { + jobs.find { it.task.camera === camera }?.unpause() + } + fun stop(camera: Camera) { jobs.find { it.task.camera === camera }?.stop() } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureState.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureState.kt index 4052d019a..7b575976f 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureState.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureState.kt @@ -8,6 +8,9 @@ enum class CameraCaptureState { WAITING, SETTLING, DITHERING, + STACKING, + PAUSING, + PAUSED, EXPOSURE_FINISHED, CAPTURE_FINISHED, } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt index 6d6ade6f0..a8ecd486c 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt @@ -2,20 +2,28 @@ package nebulosa.api.cameras import com.fasterxml.jackson.annotation.JsonIgnore import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.calibration.CalibrationFrameProvider +import nebulosa.api.guiding.DitherAfterExposureEvent import nebulosa.api.guiding.DitherAfterExposureTask import nebulosa.api.guiding.WaitForSettleTask +import nebulosa.api.livestacker.LiveStackingRequest import nebulosa.api.tasks.AbstractTask import nebulosa.api.tasks.SplitTask import nebulosa.api.tasks.delay.DelayEvent import nebulosa.api.tasks.delay.DelayTask import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.common.concurrency.latch.PauseListener import nebulosa.guiding.Guider import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.CameraEvent +import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.livestacker.LiveStacker import nebulosa.log.loggerFor import java.nio.file.Path import java.time.Duration import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.io.path.exists data class CameraCaptureTask( @JvmField val camera: Camera, @@ -24,7 +32,8 @@ data class CameraCaptureTask( private val useFirstExposure: Boolean = false, private val exposureMaxRepeat: Int = 0, private val executor: Executor? = null, -) : AbstractTask(), Consumer, CameraEventAware { + private val calibrationFrameProvider: CalibrationFrameProvider? = null, +) : AbstractTask(), Consumer, PauseListener, CameraEventAware { private val delayTask = DelayTask(request.exposureDelay) private val waitForSettleTask = WaitForSettleTask(guider) @@ -32,7 +41,6 @@ data class CameraCaptureTask( private val cameraExposureTask = CameraExposureTask(camera, request) private val ditherAfterExposureTask = DitherAfterExposureTask(guider, request.dither) - @Volatile private var state = CameraCaptureState.IDLE @Volatile private var exposureCount = 0 @Volatile private var captureRemainingTime = Duration.ZERO @Volatile private var prevCaptureElapsedTime = Duration.ZERO @@ -41,12 +49,16 @@ data class CameraCaptureTask( @Volatile private var stepRemainingTime = Duration.ZERO @Volatile private var stepElapsedTime = Duration.ZERO @Volatile private var stepProgress = 0.0 - @Volatile private var savePath: Path? = null + @Volatile private var savedPath: Path? = null + @Volatile private var liveStackedPath: Path? = null @JvmField @JsonIgnore val estimatedCaptureTime: Duration = if (request.isLoop) Duration.ZERO else Duration.ofNanos(request.exposureTime.toNanos() * request.exposureAmount + request.exposureDelay.toNanos() * (request.exposureAmount - if (useFirstExposure) 0 else 1)) @Volatile private var exposureRepeatCount = 0 + @Volatile private var liveStacker: LiveStacker? = null + + private val pausing = AtomicBoolean() init { delayTask.subscribe(this) @@ -62,19 +74,93 @@ data class CameraCaptureTask( cameraExposureTask.handleCameraEvent(event) } + override fun onPause(paused: Boolean) { + pausing.set(paused) + + if (paused) { + sendEvent(CameraCaptureState.PAUSING) + } + } + + private fun LiveStackingRequest.processCalibrationGroup(): LiveStackingRequest { + return if (calibrationFrameProvider != null && enabled && + !request.calibrationGroup.isNullOrBlank() && (dark == null || flat == null || bias == null) + ) { + val calibrationGroup = request.calibrationGroup + val temperature = camera.temperature + val binX = request.binX + val binY = request.binY + val width = request.width / binX + val height = request.height / binY + val exposureTime = request.exposureTime.toNanos() / 1000 + val gain = request.gain.toDouble() + + val wheel = camera.snoopedDevices.firstOrNull { it is FilterWheel } as? FilterWheel + val filter = wheel?.let { it.names.getOrNull(it.position - 1) } + + LOG.info( + "find calibration frames for live stacking. group={}, temperature={}, binX={}, binY={}. width={}, height={}, exposureTime={}, gain={}, filter={}", + calibrationGroup, temperature, binX, binY, width, height, exposureTime, gain, filter + ) + + val newDark = dark?.takeIf { it.exists() } ?: calibrationFrameProvider + .findBestDarkFrames(calibrationGroup, temperature, width, height, binX, binY, exposureTime, gain) + .firstOrNull() + ?.path + + val newFlat = flat?.takeIf { it.exists() } ?: calibrationFrameProvider + .findBestFlatFrames(calibrationGroup, width, height, binX, binY, filter) + .firstOrNull() + ?.path + + val newBias = if (newDark != null) null else bias?.takeIf { it.exists() } ?: calibrationFrameProvider + .findBestBiasFrames(calibrationGroup, width, height, binX, binY) + .firstOrNull() + ?.path + + LOG.info("live stacking will use calibration frames. group={}, dark={}, flat={}, bias={}", calibrationGroup, newDark, newFlat, newBias) + + copy(dark = newDark, flat = newFlat, bias = newBias) + } else { + this + } + } + override fun execute(cancellationToken: CancellationToken) { LOG.info("Camera Capture started. camera={}, request={}, exposureCount={}", camera, request, exposureCount) cameraExposureTask.reset() + pausing.set(false) + cancellationToken.listenToPause(this) + + if (liveStacker == null && request.liveStacking.enabled && + (request.isLoop || request.exposureAmount > 1 || exposureMaxRepeat > 1) + ) { + try { + liveStacker = request.liveStacking.processCalibrationGroup().get() + liveStacker!!.start() + } catch (e: Throwable) { + LOG.error("failed to start live stacking. request={}", request.liveStacking, e) + + liveStacker?.close() + liveStacker = null + } + } + while (!cancellationToken.isCancelled && !cameraExposureTask.isAborted && ((exposureMaxRepeat > 0 && exposureRepeatCount < exposureMaxRepeat) || (exposureMaxRepeat <= 0 && (request.isLoop || exposureCount < request.exposureAmount))) ) { + if (cancellationToken.isPaused) { + pausing.set(false) + sendEvent(CameraCaptureState.PAUSED) + cancellationToken.waitForPause() + } + if (exposureCount == 0) { - state = CameraCaptureState.CAPTURE_STARTED - sendEvent() + sendEvent(CameraCaptureState.CAPTURE_STARTED) if (guider != null) { if (useFirstExposure) { @@ -107,11 +193,12 @@ data class CameraCaptureTask( } } - if (state != CameraCaptureState.CAPTURE_FINISHED) { - state = CameraCaptureState.CAPTURE_FINISHED - sendEvent() - } + pausing.set(false) + cancellationToken.unlistenToPause(this) + + sendEvent(CameraCaptureState.CAPTURE_FINISHED) + liveStacker?.close() exposureRepeatCount = 0 LOG.info("Camera Capture finished. camera={}, request={}, exposureCount={}", camera, request, exposureCount) @@ -119,72 +206,90 @@ data class CameraCaptureTask( @Synchronized override fun accept(event: Any) { - when (event) { + val state = when (event) { is DelayEvent -> { - state = CameraCaptureState.WAITING captureElapsedTime += event.waitTime stepElapsedTime = event.task.duration - event.remainingTime stepRemainingTime = event.remainingTime stepProgress = event.progress + CameraCaptureState.WAITING } is CameraExposureEvent -> { when (event.state) { CameraExposureState.STARTED -> { - state = CameraCaptureState.EXPOSURE_STARTED prevCaptureElapsedTime = captureElapsedTime exposureCount++ exposureRepeatCount++ + CameraCaptureState.EXPOSURE_STARTED } CameraExposureState.ELAPSED -> { - state = CameraCaptureState.EXPOSURING captureElapsedTime = prevCaptureElapsedTime + event.elapsedTime stepElapsedTime = event.elapsedTime stepRemainingTime = event.remainingTime stepProgress = event.progress + CameraCaptureState.EXPOSURING } CameraExposureState.FINISHED -> { - state = CameraCaptureState.EXPOSURE_FINISHED captureElapsedTime = prevCaptureElapsedTime + request.exposureTime - savePath = event.savedPath + savedPath = event.savedPath + liveStackedPath = addFrameToLiveStacker(savedPath) + CameraCaptureState.EXPOSURE_FINISHED } CameraExposureState.IDLE -> { - state = CameraCaptureState.CAPTURE_FINISHED + CameraCaptureState.CAPTURE_FINISHED } } } - else -> return LOG.warn("unknown event: {}", event) + is DitherAfterExposureEvent -> { + CameraCaptureState.DITHERING + } + else -> { + return LOG.warn("unknown event: {}", event) + } } - sendEvent() + sendEvent(state) } - private fun sendEvent() { + private fun sendEvent(state: CameraCaptureState) { if (state != CameraCaptureState.IDLE && !request.isLoop) { captureRemainingTime = if (estimatedCaptureTime > captureElapsedTime) estimatedCaptureTime - captureElapsedTime else Duration.ZERO captureProgress = (estimatedCaptureTime - captureRemainingTime).toNanos().toDouble() / estimatedCaptureTime.toNanos() } + val isExposureFinished = state == CameraCaptureState.EXPOSURE_FINISHED + val event = CameraCaptureEvent( - camera, state, request.exposureAmount, exposureCount, + this, camera, if (pausing.get() && !isExposureFinished) CameraCaptureState.PAUSING else state, request.exposureAmount, exposureCount, captureRemainingTime, captureElapsedTime, captureProgress, stepRemainingTime, stepElapsedTime, stepProgress, - savePath + savedPath, liveStackedPath, + if (isExposureFinished) request else null ) onNext(event) } + private fun addFrameToLiveStacker(path: Path?): Path? { + return if (path == null || liveStacker == null) { + null + } else { + sendEvent(CameraCaptureState.STACKING) + liveStacker!!.add(path) + } + } + override fun close() { delayTask.close() waitForSettleTask.close() delayAndWaitForSettleSplitTask.close() cameraExposureTask.close() ditherAfterExposureTask.close() + liveStacker?.close() super.close() } override fun reset() { - state = CameraCaptureState.IDLE exposureCount = 0 captureRemainingTime = Duration.ZERO prevCaptureElapsedTime = Duration.ZERO @@ -193,12 +298,14 @@ data class CameraCaptureTask( stepRemainingTime = Duration.ZERO stepElapsedTime = Duration.ZERO stepProgress = 0.0 - savePath = null + savedPath = null + liveStackedPath = null delayTask.reset() cameraExposureTask.reset() ditherAfterExposureTask.reset() + pausing.set(false) exposureRepeatCount = 0 } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt index 58f4e460b..38dd89fc6 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt @@ -61,6 +61,16 @@ class CameraController( @RequestBody body: CameraStartCaptureRequest, ) = cameraService.startCapture(camera, body) + @PutMapping("{camera}/capture/pause") + fun pauseCapture(camera: Camera) { + cameraService.pauseCapture(camera) + } + + @PutMapping("{camera}/capture/unpause") + fun unpauseCapture(camera: Camera) { + cameraService.unpauseCapture(camera) + } + @PutMapping("{camera}/capture/abort") fun abortCapture(camera: Camera) { cameraService.abortCapture(camera) diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt index ddd92e97a..f92b075bf 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt @@ -43,6 +43,14 @@ class CameraService( cameraCaptureExecutor.execute(camera, request.copy(savePath = savePath)) } + fun pauseCapture(camera: Camera) { + cameraCaptureExecutor.pause(camera) + } + + fun unpauseCapture(camera: Camera) { + cameraCaptureExecutor.unpause(camera) + } + @Synchronized fun abortCapture(camera: Camera) { cameraCaptureExecutor.stop(camera) diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt index c39ab7323..ac243e9d7 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt @@ -4,6 +4,7 @@ import jakarta.validation.Valid import jakarta.validation.constraints.Positive import jakarta.validation.constraints.PositiveOrZero import nebulosa.api.guiding.DitherAfterExposureRequest +import nebulosa.api.livestacker.LiveStackingRequest import nebulosa.indi.device.camera.FrameType import org.hibernate.validator.constraints.Range import org.hibernate.validator.constraints.time.DurationMax @@ -35,6 +36,7 @@ data class CameraStartCaptureRequest( @JvmField val autoSubFolderMode: AutoSubFolderMode = AutoSubFolderMode.OFF, @field:Valid @JvmField val dither: DitherAfterExposureRequest = DitherAfterExposureRequest.DISABLED, @JvmField val calibrationGroup: String? = null, + @JvmField val liveStacking: LiveStackingRequest = LiveStackingRequest.EMPTY, // Filter Wheel. @JvmField val filterPosition: Int = 0, @JvmField val shutterPosition: Int = 0, diff --git a/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt b/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt index 6a31eb37a..5ed33f323 100644 --- a/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt +++ b/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt @@ -7,7 +7,7 @@ import nebulosa.image.Image import nebulosa.io.transferAndCloseOutput import nebulosa.log.loggerFor import nebulosa.math.Angle -import nebulosa.plate.solving.PlateSolution +import nebulosa.platesolver.PlateSolution import org.springframework.stereotype.Service import java.nio.file.Files import java.nio.file.Path diff --git a/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureTask.kt b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureTask.kt index aa59f6828..74e58df06 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureTask.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureTask.kt @@ -19,7 +19,6 @@ data class DitherAfterExposureTask( private val ditherLatch = CountUpDownLatch() - @Volatile private var state = DitherAfterExposureState.IDLE @Volatile private var dx = 0.0 @Volatile private var dy = 0.0 @Volatile private var elapsedTime = Duration.ZERO @@ -36,21 +35,19 @@ data class DitherAfterExposureTask( guider.registerGuiderListener(this) ditherLatch.countUp() - state = DitherAfterExposureState.STARTED - sendEvent() + sendEvent(DitherAfterExposureState.STARTED) elapsedTime = Duration.ofMillis(measureTimeMillis { guider.dither(request.amount, request.raOnly) ditherLatch.await() }) } finally { - state = DitherAfterExposureState.FINISHED - sendEvent() + sendEvent(DitherAfterExposureState.FINISHED) guider.unregisterGuiderListener(this) cancellationToken.unlisten(this) - LOG.info("Dither finished. request={}", request) + LOG.info("Dither finished. elapsedTime={}, request={}", elapsedTime, request) } } } @@ -58,10 +55,9 @@ data class DitherAfterExposureTask( override fun onDithered(dx: Double, dy: Double) { this.dx = dx this.dy = dy - state = DitherAfterExposureState.DITHERED + sendEvent(DitherAfterExposureState.DITHERED) LOG.info("dithered. dx={}, dy={}", dx, dy) - ditherLatch.reset() } @@ -73,10 +69,9 @@ data class DitherAfterExposureTask( dx = 0.0 dy = 0.0 elapsedTime = Duration.ZERO - state = DitherAfterExposureState.IDLE } - private fun sendEvent() { + private fun sendEvent(state: DitherAfterExposureState) { onNext(DitherAfterExposureEvent(this, state, dx, dy, elapsedTime)) } diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt b/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt index 7d51daeb3..ac5670f67 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt @@ -2,26 +2,33 @@ package nebulosa.api.image import nebulosa.fits.fits import nebulosa.image.Image -import nebulosa.plate.solving.PlateSolution +import nebulosa.log.loggerFor +import nebulosa.platesolver.PlateSolution import nebulosa.xisf.xisf import org.springframework.stereotype.Component +import java.io.Closeable import java.nio.file.Path +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import kotlin.concurrent.timer import kotlin.io.path.extension @Component -class ImageBucket { +class ImageBucket : Closeable { data class OpenedImage( - @JvmField val image: Image, + @JvmField var image: Image? = null, @JvmField var solution: PlateSolution? = null, @JvmField val debayer: Boolean = true, + @JvmField var openedAt: Long = System.currentTimeMillis(), ) - private val bucket = HashMap(256) + private val bucket = ConcurrentHashMap(8) + private val timer = timer("Image Bucket Timer", true, IMAGES_MAX_TIME, IMAGES_MAX_TIME, ::deleteUnusedImages) @Synchronized - fun put(path: Path, image: Image, solution: PlateSolution? = null, debayer: Boolean = true) { - bucket[path] = OpenedImage(image, solution ?: PlateSolution.from(image.header), debayer) + fun put(path: Path, image: Image, solution: PlateSolution? = null, debayer: Boolean = true): OpenedImage { + return OpenedImage(image, solution ?: PlateSolution.from(image.header), debayer).also { bucket[path] = it } } @Synchronized @@ -32,11 +39,17 @@ class ImageBucket { } @Synchronized - fun open(path: Path, debayer: Boolean = true, solution: PlateSolution? = null, force: Boolean = false): Image { + fun open( + path: Path, debayer: Boolean = this[path]?.debayer ?: true, + solution: PlateSolution? = null, force: Boolean = false + ): OpenedImage { val openedImage = this[path] if (openedImage != null && !force && debayer == openedImage.debayer) { - return openedImage.image + if (openedImage.image != null && solution == null) { + openedImage.openedAt = System.currentTimeMillis() + return openedImage + } } val representation = when (path.extension.lowercase()) { @@ -46,8 +59,7 @@ class ImageBucket { } val image = representation.use { Image.open(it, debayer) } - put(path, image, solution, debayer) - return image + return put(path, image, solution ?: openedImage?.solution, debayer) } @Synchronized @@ -60,7 +72,7 @@ class ImageBucket { } operator fun contains(path: Path): Boolean { - return path in bucket + return bucket.containsKey(path) } operator fun contains(image: Image): Boolean { @@ -70,4 +82,29 @@ class ImageBucket { operator fun contains(solution: PlateSolution): Boolean { return bucket.any { it.value.solution === solution } } + + override fun close() { + timer.cancel() + } + + @Suppress("UNUSED_PARAMETER") + private fun deleteUnusedImages(task: TimerTask) { + val currentTime = System.currentTimeMillis() + + synchronized(this) { + for ((path, image) in bucket) { + if (currentTime - image.openedAt >= IMAGES_MAX_TIME) { + image.image = null + LOG.info("image at {} has been disposed", path) + } + } + } + } + + companion object { + + private const val IMAGES_MAX_TIME = 1000 * 60 * 5L // 5 min + + @JvmStatic private val LOG = loggerFor() + } } diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt index ff452a3e2..d73785c67 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt @@ -82,8 +82,8 @@ class ImageService( path: Path, camera: Camera?, transformation: ImageTransformation, output: HttpServletResponse, ) { - val image = imageBucket.open(path, transformation.debayer, force = transformation.force) - val (transformedImage, statistics, stretchParams, instrument) = image.transform(true, transformation, ImageOperation.OPEN, camera) + val (image, calibration) = imageBucket.open(path, transformation.debayer, force = transformation.force) + val (transformedImage, statistics, stretchParams, instrument) = image!!.transform(true, transformation, ImageOperation.OPEN, camera) val info = ImageInfo( path, @@ -91,7 +91,7 @@ class ImageService( stretchParams!!.shadow, stretchParams.highlight, stretchParams.midtone, transformedImage.header.rightAscension.takeIf { it.isFinite() }, transformedImage.header.declination.takeIf { it.isFinite() }, - imageBucket[path]?.solution?.let(::ImageSolved), + calibration?.let(::ImageSolved), transformedImage.header.mapNotNull { if (it.isCommentStyle) null else ImageHeaderItem(it.key, it.value) }, transformedImage.header.bitpix, instrument, statistics, ) @@ -170,9 +170,9 @@ class ImageService( minorPlanetMagLimit: Double = 12.0, useSimbad: Boolean = false, location: Location? = null, ): List { - val (image, calibration) = imageBucket[path] ?: return emptyList() + val (image, calibration) = imageBucket.open(path) - if (calibration.isNullOrEmpty() || !calibration.solved) { + if (image == null || calibration.isNullOrEmpty() || !calibration.solved) { return emptyList() } @@ -267,8 +267,8 @@ class ImageService( return annotations } - fun saveImageAs(inputPath: Path, save: SaveImage, camera: Camera?) { - val (image) = imageBucket[inputPath]?.image?.transform(save.shouldBeTransformed, save.transformation, ImageOperation.SAVE) + fun saveImageAs(path: Path, save: SaveImage, camera: Camera?) { + val (image) = imageBucket.open(path).image?.transform(save.shouldBeTransformed, save.transformation, ImageOperation.SAVE) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found") require(save.path != null) @@ -295,9 +295,9 @@ class ImageService( } fun coordinateInterpolation(path: Path): CoordinateInterpolation? { - val (image, calibration) = imageBucket[path] ?: return null + val (image, calibration) = imageBucket.open(path) - if (calibration.isNullOrEmpty() || !calibration.solved) { + if (image == null || calibration.isNullOrEmpty() || !calibration.solved) { return null } @@ -331,8 +331,7 @@ class ImageService( } fun histogram(path: Path, bitLength: Int = 16): IntArray { - val (image) = imageBucket[path] ?: return IntArray(0) - return image.compute(Histogram(bitLength = bitLength)) + return imageBucket.open(path).image?.compute(Histogram(bitLength = bitLength)) ?: IntArray(0) } companion object { diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt b/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt index aaf2a8122..5823fffc0 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt @@ -1,7 +1,7 @@ package nebulosa.api.image import nebulosa.math.* -import nebulosa.plate.solving.PlateSolution +import nebulosa.platesolver.PlateSolution data class ImageSolved( val solved: Boolean = false, diff --git a/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackerType.kt b/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackerType.kt new file mode 100644 index 000000000..c6d1dbe84 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackerType.kt @@ -0,0 +1,6 @@ +package nebulosa.api.livestacker + +enum class LiveStackerType { + SIRIL, + PIXINSIGHT, +} diff --git a/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackingController.kt b/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackingController.kt new file mode 100644 index 000000000..03acb8096 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackingController.kt @@ -0,0 +1,27 @@ +package nebulosa.api.livestacker + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import nebulosa.indi.device.camera.Camera +import org.springframework.web.bind.annotation.* +import java.nio.file.Path + +@RestController +@RequestMapping("live-stacking") +class LiveStackingController(private val liveStackingService: LiveStackingService) { + + @PutMapping("{camera}/start") + fun start(camera: Camera, @RequestBody body: LiveStackingRequest) { + liveStackingService.start(camera, body) + } + + @PutMapping("{camera}/add") + fun add(camera: Camera, @RequestParam @Valid @NotBlank path: Path): Path? { + return liveStackingService.add(camera, path) + } + + @PutMapping("{camera}/stop") + fun stop(camera: Camera) { + liveStackingService.stop(camera) + } +} diff --git a/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackingRequest.kt b/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackingRequest.kt new file mode 100644 index 000000000..f44d606ff --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackingRequest.kt @@ -0,0 +1,48 @@ +package nebulosa.api.livestacker + +import nebulosa.livestacker.LiveStacker +import nebulosa.pixinsight.livestacker.PixInsightLiveStacker +import nebulosa.pixinsight.script.PixInsightIsRunning +import nebulosa.pixinsight.script.PixInsightScriptRunner +import nebulosa.pixinsight.script.PixInsightStartup +import nebulosa.siril.livestacker.SirilLiveStacker +import org.jetbrains.annotations.NotNull +import java.nio.file.Files +import java.nio.file.Path +import java.util.function.Supplier + +data class LiveStackingRequest( + @JvmField val enabled: Boolean = false, + @JvmField val type: LiveStackerType = LiveStackerType.SIRIL, + @JvmField @field:NotNull val executablePath: Path? = null, + @JvmField val dark: Path? = null, + @JvmField val flat: Path? = null, + @JvmField val bias: Path? = null, + @JvmField val use32Bits: Boolean = false, + @JvmField val slot: Int = 1, +) : Supplier { + + override fun get(): LiveStacker { + val workingDirectory = Files.createTempDirectory("ls-") + + return when (type) { + LiveStackerType.SIRIL -> SirilLiveStacker(executablePath!!, workingDirectory, dark, flat, use32Bits) + LiveStackerType.PIXINSIGHT -> { + val runner = PixInsightScriptRunner(executablePath!!) + + if (!PixInsightIsRunning(slot).use { it.runSync(runner) }) { + if (!PixInsightStartup(slot).use { it.runSync(runner) }) { + throw IllegalStateException("unable to start PixInsight") + } + } + + PixInsightLiveStacker(runner, workingDirectory, dark, flat, bias, use32Bits, slot) + } + } + } + + companion object { + + @JvmStatic val EMPTY = LiveStackingRequest() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackingService.kt b/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackingService.kt new file mode 100644 index 000000000..deebb2c68 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackingService.kt @@ -0,0 +1,29 @@ +package nebulosa.api.livestacker + +import nebulosa.indi.device.camera.Camera +import nebulosa.livestacker.LiveStacker +import org.springframework.stereotype.Service +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap + +@Service +class LiveStackingService { + + private val liveStackers = ConcurrentHashMap(2) + + fun start(camera: Camera, options: LiveStackingRequest) { + stop(camera) + + val liveStacker = options.get() + liveStackers[camera] = liveStacker + liveStacker.start() + } + + fun add(camera: Camera, path: Path): Path? { + return liveStackers[camera]?.add(path) + } + + fun stop(camera: Camera) { + liveStackers.remove(camera)?.stop() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt index 08d2e882f..e60a61a74 100644 --- a/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt @@ -224,11 +224,10 @@ class MountService( } fun pointMountHere(mount: Mount, path: Path, x: Double, y: Double) { - val calibration = imageBucket[path]?.solution ?: return + val calibration = imageBucket.open(path).solution ?: return if (calibration.isNotEmpty() && calibration.solved) { - val wcs = WCS(calibration) - val (rightAscension, declination) = wcs.use { it.pixToSky(x, y) } // J2000 + val (rightAscension, declination) = WCS(calibration).use { it.pixToSky(x, y) } // J2000 val icrf = ICRF.equatorial(calibration.rightAscension, calibration.declination) val (calibratedRA, calibratedDEC) = icrf.equatorialAtDate() diff --git a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverController.kt b/api/src/main/kotlin/nebulosa/api/platesolver/PlateSolverController.kt similarity index 90% rename from api/src/main/kotlin/nebulosa/api/solver/PlateSolverController.kt rename to api/src/main/kotlin/nebulosa/api/platesolver/PlateSolverController.kt index f4936f0fa..b21d1c08d 100644 --- a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverController.kt +++ b/api/src/main/kotlin/nebulosa/api/platesolver/PlateSolverController.kt @@ -1,4 +1,4 @@ -package nebulosa.api.solver +package nebulosa.api.platesolver import jakarta.validation.Valid import nebulosa.api.beans.converters.angle.AngleParam @@ -15,7 +15,7 @@ class PlateSolverController( @PutMapping fun solveImage( @RequestParam path: Path, - @RequestBody @Valid solver: PlateSolverOptions, + @RequestBody @Valid solver: PlateSolverRequest, @RequestParam(required = false, defaultValue = "true") blind: Boolean, @AngleParam(required = false, isHours = true, defaultValue = "0.0") centerRA: Angle, @AngleParam(required = false, defaultValue = "0.0") centerDEC: Angle, diff --git a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt b/api/src/main/kotlin/nebulosa/api/platesolver/PlateSolverRequest.kt similarity index 72% rename from api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt rename to api/src/main/kotlin/nebulosa/api/platesolver/PlateSolverRequest.kt index 1612c43cc..dfd73f7d0 100644 --- a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt +++ b/api/src/main/kotlin/nebulosa/api/platesolver/PlateSolverRequest.kt @@ -1,9 +1,10 @@ -package nebulosa.api.solver +package nebulosa.api.platesolver -import nebulosa.astap.plate.solving.AstapPlateSolver +import nebulosa.astap.platesolver.AstapPlateSolver import nebulosa.astrometrynet.nova.NovaAstrometryNetService -import nebulosa.astrometrynet.plate.solving.LocalAstrometryNetPlateSolver -import nebulosa.astrometrynet.plate.solving.NovaAstrometryNetPlateSolver +import nebulosa.astrometrynet.platesolver.LocalAstrometryNetPlateSolver +import nebulosa.astrometrynet.platesolver.NovaAstrometryNetPlateSolver +import nebulosa.siril.platesolver.SirilPlateSolver import okhttp3.OkHttpClient import org.hibernate.validator.constraints.time.DurationMax import org.hibernate.validator.constraints.time.DurationMin @@ -12,10 +13,12 @@ import java.nio.file.Path import java.time.Duration import java.time.temporal.ChronoUnit -data class PlateSolverOptions( +data class PlateSolverRequest( @JvmField val type: PlateSolverType = PlateSolverType.ASTROMETRY_NET_ONLINE, @JvmField val executablePath: Path? = null, @JvmField val downsampleFactor: Int = 0, + @JvmField val focalLength: Double = 0.0, + @JvmField val pixelSize: Double = 0.0, @JvmField val apiUrl: String = "", @JvmField val apiKey: String = "", @field:DurationMin(seconds = 0) @field:DurationMax(minutes = 5) @field:DurationUnit(ChronoUnit.SECONDS) @@ -31,12 +34,13 @@ data class PlateSolverOptions( val service = NOVA_ASTROMETRY_NET_CACHE.getOrPut(key) { NovaAstrometryNetService(apiUrl, httpClient) } NovaAstrometryNetPlateSolver(service, apiKey) } + PlateSolverType.SIRIL -> SirilPlateSolver(executablePath!!, focalLength, pixelSize) } } companion object { - @JvmStatic val EMPTY = PlateSolverOptions() + @JvmStatic val EMPTY = PlateSolverRequest() @JvmStatic private val NOVA_ASTROMETRY_NET_CACHE = HashMap() } } diff --git a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt b/api/src/main/kotlin/nebulosa/api/platesolver/PlateSolverService.kt similarity index 72% rename from api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt rename to api/src/main/kotlin/nebulosa/api/platesolver/PlateSolverService.kt index 61f0e0790..aa7fbb45c 100644 --- a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt +++ b/api/src/main/kotlin/nebulosa/api/platesolver/PlateSolverService.kt @@ -1,4 +1,4 @@ -package nebulosa.api.solver +package nebulosa.api.platesolver import nebulosa.api.image.ImageBucket import nebulosa.api.image.ImageSolved @@ -14,7 +14,7 @@ class PlateSolverService( ) { fun solveImage( - options: PlateSolverOptions, path: Path, + options: PlateSolverRequest, path: Path, centerRA: Angle, centerDEC: Angle, radius: Angle, ): ImageSolved { val calibration = solve(options, path, centerRA, centerDEC, radius) @@ -24,8 +24,7 @@ class PlateSolverService( @Synchronized fun solve( - options: PlateSolverOptions, path: Path, + options: PlateSolverRequest, path: Path, centerRA: Angle = 0.0, centerDEC: Angle = 0.0, radius: Angle = 0.0, - ) = options.get(httpClient) - .solve(path, null, centerRA, centerDEC, radius, 1, options.timeout.takeIf { it.toSeconds() > 0 }) + ) = options.get(httpClient).solve(path, null, centerRA, centerDEC, radius, options.downsampleFactor, options.timeout) } diff --git a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverType.kt b/api/src/main/kotlin/nebulosa/api/platesolver/PlateSolverType.kt similarity index 67% rename from api/src/main/kotlin/nebulosa/api/solver/PlateSolverType.kt rename to api/src/main/kotlin/nebulosa/api/platesolver/PlateSolverType.kt index 4ef5b34f5..3c85a9a86 100644 --- a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverType.kt +++ b/api/src/main/kotlin/nebulosa/api/platesolver/PlateSolverType.kt @@ -1,7 +1,8 @@ -package nebulosa.api.solver +package nebulosa.api.platesolver enum class PlateSolverType { ASTAP, ASTROMETRY_NET, ASTROMETRY_NET_ONLINE, + SIRIL, } diff --git a/api/src/main/kotlin/nebulosa/api/preferences/PreferenceRepository.kt b/api/src/main/kotlin/nebulosa/api/preferences/PreferenceRepository.kt index c341870c5..ce5ea8fba 100644 --- a/api/src/main/kotlin/nebulosa/api/preferences/PreferenceRepository.kt +++ b/api/src/main/kotlin/nebulosa/api/preferences/PreferenceRepository.kt @@ -1,7 +1,7 @@ package nebulosa.api.preferences import io.objectbox.Box -import io.objectbox.query.QueryBuilder.StringOrder.CASE_SENSITIVE +import io.objectbox.kotlin.equal import nebulosa.api.repositories.BoxRepository import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Component @@ -10,24 +10,18 @@ import org.springframework.stereotype.Component class PreferenceRepository(@Qualifier("preferenceBox") override val box: Box) : BoxRepository() { fun existsByKey(key: String): Boolean { - return box.query() - .equal(PreferenceEntity_.key, key, CASE_SENSITIVE) - .build() - .use { it.findUnique() != null } + return box.query(PreferenceEntity_.key equal key) + .build().use { it.findUnique() != null } } fun findByKey(key: String): PreferenceEntity? { - return box.query() - .equal(PreferenceEntity_.key, key, CASE_SENSITIVE) - .build() - .use { it.findUnique() } + return box.query(PreferenceEntity_.key equal key) + .build().use { it.findUnique() } } @Synchronized fun deleteByKey(key: String) { - return box.query() - .equal(PreferenceEntity_.key, key, CASE_SENSITIVE) - .build() - .use { it.remove() } + return box.query(PreferenceEntity_.key equal key) + .build().use { it.remove() } } } diff --git a/api/src/main/kotlin/nebulosa/api/repositories/BoxRepository.kt b/api/src/main/kotlin/nebulosa/api/repositories/BoxRepository.kt index 98223b3c4..702837d54 100644 --- a/api/src/main/kotlin/nebulosa/api/repositories/BoxRepository.kt +++ b/api/src/main/kotlin/nebulosa/api/repositories/BoxRepository.kt @@ -1,6 +1,13 @@ package nebulosa.api.repositories import io.objectbox.Box +import io.objectbox.Property +import io.objectbox.kotlin.and +import io.objectbox.kotlin.equal +import io.objectbox.kotlin.or +import io.objectbox.query.PropertyQueryCondition +import io.objectbox.query.QueryBuilder.StringOrder +import io.objectbox.query.QueryCondition import nebulosa.api.database.BoxEntity abstract class BoxRepository : Collection { @@ -58,4 +65,53 @@ abstract class BoxRepository : Collection { override fun contains(element: T): Boolean { return element.id in box } + + companion object { + + inline val Property.isTrue + get() = this equal true + + inline val Property.isFalse + get() = this equal false + + @Suppress("NOTHING_TO_INLINE") + inline infix fun Property.equalInsensitive(value: String): PropertyQueryCondition { + return equal(value, StringOrder.CASE_INSENSITIVE) + } + + @Suppress("NOTHING_TO_INLINE") + inline infix fun Property.containsInsensitive(value: String): PropertyQueryCondition { + return contains(value, StringOrder.CASE_INSENSITIVE) + } + + @JvmStatic + fun and(condition: QueryCondition, vararg conditions: QueryCondition?): QueryCondition { + return conditions.fold(condition) { a, b -> if (b == null) a else a and b } + } + + @JvmStatic + fun and(vararg conditions: QueryCondition?): QueryCondition? { + return if (conditions.isEmpty()) null else conditions.reduce { a, b -> if (b == null) a else a?.and(b) ?: b } + } + + @JvmStatic + fun and(conditions: Collection?>): QueryCondition? { + return if (conditions.isEmpty()) null else conditions.reduce { a, b -> if (b == null) a else a?.and(b) ?: b } + } + + @JvmStatic + fun or(condition: QueryCondition, vararg conditions: QueryCondition?): QueryCondition { + return conditions.fold(condition) { a, b -> if (b == null) a else a or b } + } + + @JvmStatic + fun or(vararg conditions: QueryCondition?): QueryCondition? { + return if (conditions.isEmpty()) null else conditions.reduce { a, b -> if (b == null) a else a?.or(b) ?: b } + } + + @JvmStatic + fun or(conditions: Collection?>): QueryCondition? { + return if (conditions.isEmpty()) null else conditions.reduce { a, b -> if (b == null) a else a?.or(b) ?: b } + } + } } diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt index fd36445da..17f9d715a 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt @@ -5,6 +5,7 @@ import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.filterwheel.FilterWheel import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.rotator.Rotator import org.springframework.web.bind.annotation.* @RestController @@ -16,9 +17,9 @@ class SequencerController( @PutMapping("{camera}/start") fun start( camera: Camera, - mount: Mount?, wheel: FilterWheel?, focuser: Focuser?, + mount: Mount?, wheel: FilterWheel?, focuser: Focuser?, rotator: Rotator?, @RequestBody @Valid body: SequencePlanRequest, - ) = sequencerService.start(camera, body, mount, wheel, focuser) + ) = sequencerService.start(camera, body, mount, wheel, focuser, rotator) @PutMapping("{camera}/stop") fun stop(camera: Camera) { diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt index 4992cff28..4f3bd05eb 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt @@ -2,6 +2,7 @@ package nebulosa.api.sequencer import io.reactivex.rxjava3.functions.Consumer import nebulosa.api.beans.annotations.Subscriber +import nebulosa.api.calibration.CalibrationFrameService import nebulosa.api.cameras.CameraEventAware import nebulosa.api.focusers.FocuserEventAware import nebulosa.api.messages.MessageEvent @@ -15,6 +16,7 @@ import nebulosa.indi.device.filterwheel.FilterWheelEvent import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.focuser.FocuserEvent import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.rotator.Rotator import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor @@ -27,6 +29,7 @@ class SequencerExecutor( private val messageService: MessageService, private val guider: Guider, private val threadPoolTaskExecutor: ThreadPoolTaskExecutor, + private val calibrationFrameService: CalibrationFrameService, ) : Consumer, CameraEventAware, WheelEventAware, FocuserEventAware { private val jobs = ConcurrentHashMap.newKeySet(1) @@ -52,7 +55,7 @@ class SequencerExecutor( fun execute( camera: Camera, request: SequencePlanRequest, - mount: Mount? = null, wheel: FilterWheel? = null, focuser: Focuser? = null, + mount: Mount? = null, wheel: FilterWheel? = null, focuser: Focuser? = null, rotator: Rotator? = null, ) { check(camera.connected) { "${camera.name} Camera is not connected" } check(jobs.none { it.task.camera === camera }) { "${camera.name} Sequencer Job is already in progress" } @@ -65,7 +68,11 @@ class SequencerExecutor( check(jobs.none { it.task.focuser === focuser }) { "${camera.name} Sequencer Job is already in progress" } } - val task = SequencerTask(camera, request, guider, mount, wheel, focuser, threadPoolTaskExecutor) + if (rotator != null && rotator.connected) { + check(jobs.none { it.task.rotator === rotator }) { "${camera.name} Sequencer Job is already in progress" } + } + + val task = SequencerTask(camera, request, guider, mount, wheel, focuser, rotator, threadPoolTaskExecutor, calibrationFrameService) task.subscribe(this) with(SequencerJob(task)) { diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt index fe26e8e24..871fcd1f3 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt @@ -4,6 +4,7 @@ import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.filterwheel.FilterWheel import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.rotator.Rotator import org.springframework.stereotype.Service import java.nio.file.Path import kotlin.io.path.exists @@ -18,13 +19,13 @@ class SequencerService( @Synchronized fun start( camera: Camera, request: SequencePlanRequest, - mount: Mount?, wheel: FilterWheel?, focuser: Focuser?, + mount: Mount?, wheel: FilterWheel?, focuser: Focuser?, rotator: Rotator?, ) { val savePath = request.savePath ?.takeIf { "$it".isNotBlank() && it.exists() && it.isDirectory() } ?: Path.of("$sequencesPath", (System.currentTimeMillis() / 1000).toString()) - sequencerExecutor.execute(camera, request.copy(savePath = savePath), mount, wheel, focuser) + sequencerExecutor.execute(camera, request.copy(savePath = savePath), mount, wheel, focuser, rotator) } @Synchronized diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerTask.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerTask.kt index 4efde90bf..9b624c38d 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerTask.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerTask.kt @@ -1,6 +1,7 @@ package nebulosa.api.sequencer import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.calibration.CalibrationFrameProvider import nebulosa.api.cameras.* import nebulosa.api.messages.MessageEvent import nebulosa.api.tasks.AbstractTask @@ -18,6 +19,7 @@ import nebulosa.indi.device.filterwheel.FilterWheel import nebulosa.indi.device.filterwheel.FilterWheelEvent import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.rotator.Rotator import nebulosa.log.loggerFor import java.time.Duration import java.util.* @@ -36,7 +38,9 @@ data class SequencerTask( @JvmField val mount: Mount? = null, @JvmField val wheel: FilterWheel? = null, @JvmField val focuser: Focuser? = null, + @JvmField val rotator: Rotator? = null, private val executor: Executor? = null, + private val calibrationFrameProvider: CalibrationFrameProvider? = null, ) : AbstractTask(), Consumer, CameraEventAware, WheelEventAware { private val usedEntries = plan.entries.filter { it.enabled } @@ -75,7 +79,10 @@ data class SequencerTask( request.wheelMoveTask()?.also(tasks::add) // CAPTURE. - val cameraCaptureTask = CameraCaptureTask(camera, request, guider, executor = executor) + val cameraCaptureTask = CameraCaptureTask( + camera, request, guider, executor = executor, + calibrationFrameProvider = calibrationFrameProvider + ) cameraCaptureTask.subscribe(this) estimatedCaptureTime += cameraCaptureTask.estimatedCaptureTime tasks.add(cameraCaptureTask) @@ -83,7 +90,8 @@ data class SequencerTask( } else { val sequenceIdTasks = usedEntries.map { req -> SequencerIdTask(plan.entries.indexOfFirst { it === req } + 1) } val requests = usedEntries.map { mapRequest(it) } - val cameraCaptureTasks = requests.mapIndexed { i, req -> CameraCaptureTask(camera, req, guider, i > 0, 1, executor) } + val cameraCaptureTasks = requests + .mapIndexed { i, req -> CameraCaptureTask(camera, req, guider, i > 0, 1, executor, calibrationFrameProvider) } val wheelMoveTasks = requests.map { it.wheelMoveTask() } val count = IntArray(requests.size) { usedEntries[it].exposureAmount } @@ -125,7 +133,7 @@ data class SequencerTask( override fun execute(cancellationToken: CancellationToken) { LOG.info("Sequencer started. camera={}, mount={}, wheel={}, focuser={}, plan={}", camera, mount, wheel, focuser, plan) - camera.snoop(listOf(mount, wheel, focuser)) + camera.snoop(listOf(mount, wheel, focuser, rotator)) for (task in tasks) { if (cancellationToken.isCancelled) break diff --git a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionOptions.kt b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionOptions.kt deleted file mode 100644 index 9d3711755..000000000 --- a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionOptions.kt +++ /dev/null @@ -1,24 +0,0 @@ -package nebulosa.api.stardetection - -import nebulosa.astap.star.detection.AstapStarDetector -import nebulosa.star.detection.StarDetector -import java.nio.file.Path -import java.time.Duration -import java.util.function.Supplier - -data class StarDetectionOptions( - @JvmField val type: StarDetectorType = StarDetectorType.ASTAP, - @JvmField val executablePath: Path? = null, - @JvmField val timeout: Duration = Duration.ZERO, - @JvmField val minSNR: Double = 0.0, -) : Supplier> { - - override fun get() = when (type) { - StarDetectorType.ASTAP -> AstapStarDetector(executablePath!!, minSNR) - } - - companion object { - - @JvmStatic val EMPTY = StarDetectionOptions() - } -} diff --git a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectorType.kt b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectorType.kt deleted file mode 100644 index 31ae2f97c..000000000 --- a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectorType.kt +++ /dev/null @@ -1,5 +0,0 @@ -package nebulosa.api.stardetection - -enum class StarDetectorType { - ASTAP -} diff --git a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionController.kt b/api/src/main/kotlin/nebulosa/api/stardetector/StarDetectionController.kt similarity index 83% rename from api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionController.kt rename to api/src/main/kotlin/nebulosa/api/stardetector/StarDetectionController.kt index 810c13811..64eaf5c5a 100644 --- a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionController.kt +++ b/api/src/main/kotlin/nebulosa/api/stardetector/StarDetectionController.kt @@ -1,4 +1,4 @@ -package nebulosa.api.stardetection +package nebulosa.api.stardetector import jakarta.validation.Valid import org.springframework.validation.annotation.Validated @@ -13,6 +13,6 @@ class StarDetectionController(private val starDetectionService: StarDetectionSer @PutMapping fun detectStars( @RequestParam path: Path, - @RequestBody @Valid body: StarDetectionOptions + @RequestBody @Valid body: StarDetectionRequest ) = starDetectionService.detectStars(path, body) } diff --git a/api/src/main/kotlin/nebulosa/api/stardetector/StarDetectionRequest.kt b/api/src/main/kotlin/nebulosa/api/stardetector/StarDetectionRequest.kt new file mode 100644 index 000000000..c43e42c1f --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/stardetector/StarDetectionRequest.kt @@ -0,0 +1,43 @@ +package nebulosa.api.stardetector + +import nebulosa.astap.stardetector.AstapStarDetector +import nebulosa.pixinsight.script.PixInsightIsRunning +import nebulosa.pixinsight.script.PixInsightScriptRunner +import nebulosa.pixinsight.script.PixInsightStartup +import nebulosa.pixinsight.stardetector.PixInsightStarDetector +import nebulosa.siril.stardetector.SirilStarDetector +import nebulosa.stardetector.StarDetector +import java.nio.file.Path +import java.time.Duration +import java.util.function.Supplier + +data class StarDetectionRequest( + @JvmField val type: StarDetectorType = StarDetectorType.ASTAP, + @JvmField val executablePath: Path? = null, + @JvmField val timeout: Duration = Duration.ZERO, + @JvmField val minSNR: Double = 0.0, + @JvmField val maxStars: Int = 0, + @JvmField val slot: Int = 1, +) : Supplier> { + + override fun get() = when (type) { + StarDetectorType.ASTAP -> AstapStarDetector(executablePath!!, minSNR) + StarDetectorType.SIRIL -> SirilStarDetector(executablePath!!, maxStars) + StarDetectorType.PIXINSIGHT -> { + val runner = PixInsightScriptRunner(executablePath!!) + + if (!PixInsightIsRunning(slot).use { it.runSync(runner) }) { + if (!PixInsightStartup(slot).use { it.runSync(runner) }) { + throw IllegalStateException("unable to start PixInsight") + } + } + + PixInsightStarDetector(runner, slot, minSNR, timeout) + } + } + + companion object { + + @JvmStatic val EMPTY = StarDetectionRequest() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionService.kt b/api/src/main/kotlin/nebulosa/api/stardetector/StarDetectionService.kt similarity index 56% rename from api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionService.kt rename to api/src/main/kotlin/nebulosa/api/stardetector/StarDetectionService.kt index 243239456..576bf184a 100644 --- a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionService.kt +++ b/api/src/main/kotlin/nebulosa/api/stardetector/StarDetectionService.kt @@ -1,13 +1,13 @@ -package nebulosa.api.stardetection +package nebulosa.api.stardetector -import nebulosa.star.detection.ImageStar +import nebulosa.stardetector.StarPoint import org.springframework.stereotype.Service import java.nio.file.Path @Service class StarDetectionService { - fun detectStars(path: Path, options: StarDetectionOptions): List { + fun detectStars(path: Path, options: StarDetectionRequest): List { val starDetector = options.get() return starDetector.detect(path) } diff --git a/api/src/main/kotlin/nebulosa/api/stardetector/StarDetectorType.kt b/api/src/main/kotlin/nebulosa/api/stardetector/StarDetectorType.kt new file mode 100644 index 000000000..56f51327e --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/stardetector/StarDetectorType.kt @@ -0,0 +1,7 @@ +package nebulosa.api.stardetector + +enum class StarDetectorType { + ASTAP, + PIXINSIGHT, + SIRIL, +} diff --git a/api/src/main/kotlin/nebulosa/api/stardetector/StarPointSerializer.kt b/api/src/main/kotlin/nebulosa/api/stardetector/StarPointSerializer.kt new file mode 100644 index 000000000..907df78b3 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/stardetector/StarPointSerializer.kt @@ -0,0 +1,24 @@ +package nebulosa.api.stardetector + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import nebulosa.stardetector.StarPoint +import org.springframework.stereotype.Component + +@Component +class StarPointSerializer : StdSerializer(StarPoint::class.java) { + + override fun serialize(star: StarPoint?, gen: JsonGenerator, provider: SerializerProvider) { + if (star == null) gen.writeNull() + else { + gen.writeStartObject() + gen.writeNumberField("x", star.x) + gen.writeNumberField("y", star.y) + gen.writeNumberField("hfd", star.hfd) + gen.writeNumberField("snr", star.snr) + gen.writeNumberField("flux", star.flux) + gen.writeEndObject() + } + } +} diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt index 8a0a0ec3d..4dc26907e 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt @@ -65,7 +65,7 @@ data class FlatWizardTask( capture = event if (event.state == CameraCaptureState.EXPOSURE_FINISHED) { - savedPath = event.savePath!! + savedPath = event.savedPath!! onNext(event) } diff --git a/api/src/test/kotlin/APITest.kt b/api/src/test/kotlin/APITest.kt index a71b9c688..b240c7f77 100644 --- a/api/src/test/kotlin/APITest.kt +++ b/api/src/test/kotlin/APITest.kt @@ -9,7 +9,7 @@ import nebulosa.api.autofocus.AutoFocusRequest import nebulosa.api.beans.converters.time.DurationSerializer import nebulosa.api.cameras.CameraStartCaptureRequest import nebulosa.api.connection.ConnectionType -import nebulosa.api.stardetection.StarDetectionOptions +import nebulosa.api.stardetector.StarDetectionRequest import nebulosa.common.json.PathSerializer import nebulosa.test.NonGitHubOnlyCondition import okhttp3.MediaType.Companion.toMediaType @@ -169,7 +169,7 @@ class APITest : StringSpec() { @JvmStatic private val EXPOSURE_TIME = Duration.ofSeconds(5) @JvmStatic private val CAPTURES_PATH = Path.of("/home/tiagohm/Git/nebulosa/data/captures") - @JvmStatic private val STAR_DETECTION_OPTIONS = StarDetectionOptions(executablePath = Path.of("astap")) + @JvmStatic private val STAR_DETECTION_OPTIONS = StarDetectionRequest(executablePath = Path.of("astap")) @JvmStatic private val CAMERA_START_CAPTURE_REQUEST = CameraStartCaptureRequest( exposureTime = EXPOSURE_TIME, width = 1280, height = 1024, frameFormat = "INDI_MONO", diff --git a/api/src/test/kotlin/CalibrationFrameRepositoryTest.kt b/api/src/test/kotlin/CalibrationFrameRepositoryTest.kt new file mode 100644 index 000000000..8cdd27d80 --- /dev/null +++ b/api/src/test/kotlin/CalibrationFrameRepositoryTest.kt @@ -0,0 +1,89 @@ +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.objectbox.kotlin.boxFor +import nebulosa.api.calibration.CalibrationFrameEntity +import nebulosa.api.calibration.CalibrationFrameRepository +import nebulosa.api.database.MyObjectBox +import nebulosa.indi.device.camera.FrameType +import java.util.* + +class CalibrationFrameRepositoryTest : StringSpec() { + + init { + val boxStore = MyObjectBox.builder() + .inMemory(UUID.randomUUID().toString()) + .build() + + afterSpec { + boxStore.close() + } + + val box = boxStore.boxFor() + val repository = CalibrationFrameRepository(box) + + repository.save(FrameType.DARK, 1L) + repository.save(FrameType.DARK, 2L) + repository.save(FrameType.DARK, 5L) + repository.save(FrameType.DARK, 10L) + repository.save(FrameType.DARK, 30L) + repository.save(FrameType.DARK, 60L) + repository.save(FrameType.DARK, 60L, gain = 100.0) + repository.save(FrameType.DARK, 10L, temperature = -10.0) + repository.save(FrameType.DARK, 30L, temperature = -10.0) + repository.save(FrameType.DARK, 60L, temperature = -10.0) + repository.save(FrameType.DARK, 60L, temperature = -10.0, gain = 100.0) + repository.save(FrameType.BIAS, 0L) + repository.save(FrameType.BIAS, 0L, gain = 100.0) + repository.save(FrameType.FLAT, 0L, filter = "RED") + repository.save(FrameType.FLAT, 0L, filter = "GREEN") + repository.save(FrameType.FLAT, 0L, filter = "BLUE") + repository.save(FrameType.FLAT, 0L, filter = null) + + "find all" { + repository.findAll().shouldHaveSize(17) + } + "find darks" { + repository.darkFrames(NAME, 1280, 1024, 1, 1L, 0.0).shouldHaveSize(1) + repository.darkFrames(NAME, 1280, 1024, 1, 60L, 0.0).shouldHaveSize(4) + repository.darkFrames(NAME, 1280, 1024, 1, 60L, 100.0).shouldHaveSize(2) + repository.darkFrames(NAME, 1280, 1024, 1, 60L, 50.0).shouldBeEmpty() + repository.darkFrames(NAME, 1280, 1024, 2, 60L, 100.0).shouldBeEmpty() + repository.darkFrames(NAME, 4092, 2800, 1, 60L, 100.0).shouldBeEmpty() + repository.darkFrames("ZW", 1280, 1024, 1, 1L, 0.0).shouldBeEmpty() + } + "find bias" { + repository.biasFrames(NAME, 1280, 1024, 1, 0.0).shouldHaveSize(2) + repository.biasFrames(NAME, 1280, 1024, 1, 100.0).shouldHaveSize(1) + repository.biasFrames(NAME, 1280, 1024, 1, 50.0).shouldBeEmpty() + repository.biasFrames(NAME, 1280, 1024, 2, 0.0).shouldBeEmpty() + repository.biasFrames(NAME, 4092, 2800, 1, 0.0).shouldBeEmpty() + repository.biasFrames("ZW", 1280, 1024, 2, 0.0).shouldBeEmpty() + } + "find flats" { + repository.flatFrames(NAME, null, 1280, 1024, 1).shouldHaveSize(1) + repository.flatFrames(NAME, "RED", 1280, 1024, 1).shouldHaveSize(1) + repository.flatFrames(NAME, "green", 1280, 1024, 1).shouldHaveSize(1) + repository.flatFrames(NAME, "BLUE", 1280, 1024, 1).shouldHaveSize(1) + repository.flatFrames(NAME, "RED", 1280, 1024, 2).shouldBeEmpty() + repository.flatFrames(NAME, "RED", 4092, 2800, 2).shouldBeEmpty() + repository.flatFrames(NAME, "HA", 1280, 1024, 2).shouldBeEmpty() + repository.flatFrames("ZW", "RED", 1280, 1024, 2).shouldBeEmpty() + } + } + + companion object { + + private const val NAME = "CCD Simulator" + + @JvmStatic + internal fun CalibrationFrameRepository.save( + type: FrameType, exposureTime: Long, + temperature: Double = 25.0, width: Int = 1280, height: Int = 1024, + bin: Int = 1, gain: Double = 0.0, + filter: String? = null, + ) { + save(CalibrationFrameEntity(0L, type, NAME, filter, exposureTime, temperature, width, height, bin, bin, gain)) + } + } +} diff --git a/api/src/test/kotlin/SatelliteEntityRepositoryTest.kt b/api/src/test/kotlin/SatelliteEntityRepositoryTest.kt new file mode 100644 index 000000000..759b8f6d7 --- /dev/null +++ b/api/src/test/kotlin/SatelliteEntityRepositoryTest.kt @@ -0,0 +1,53 @@ +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.objectbox.kotlin.boxFor +import nebulosa.api.atlas.SatelliteEntity +import nebulosa.api.atlas.SatelliteGroupType +import nebulosa.api.atlas.SatelliteRepository +import nebulosa.api.database.MyObjectBox +import java.util.* + +class SatelliteEntityRepositoryTest : StringSpec() { + + init { + val boxStore = MyObjectBox.builder() + .inMemory(UUID.randomUUID().toString()) + .build() + + afterSpec { + boxStore.close() + } + + val box = boxStore.boxFor() + val repository = SatelliteRepository(box) + + repository.save("ISS", SatelliteGroupType.ACTIVE, SatelliteGroupType.EDUCATION) + repository.save("StarLink", SatelliteGroupType.ACTIVE, SatelliteGroupType.STARLINK) + + "find all" { + repository.search().shouldHaveSize(2) + } + "find by name" { + repository.search("iss").shouldHaveSize(1) + } + "find by groups" { + repository.search(groups = listOf(SatelliteGroupType.ACTIVE)).shouldHaveSize(2) + repository.search(groups = listOf(SatelliteGroupType.STARLINK)).shouldHaveSize(1) + repository.search(groups = listOf(SatelliteGroupType.AMATEUR)).shouldBeEmpty() + } + "find by name and groups" { + repository.search(text = "iss", groups = listOf(SatelliteGroupType.ACTIVE)).shouldHaveSize(1) + repository.search(text = "iss", groups = listOf(SatelliteGroupType.STARLINK)).shouldBeEmpty() + repository.search(text = "starlink", groups = listOf(SatelliteGroupType.EDUCATION)).shouldBeEmpty() + } + } + + companion object { + + @JvmStatic + internal fun SatelliteRepository.save(name: String, vararg groups: SatelliteGroupType) { + save(SatelliteEntity(0L, name, "", groups.map { it.name }.toMutableList())) + } + } +} diff --git a/api/src/test/kotlin/SimbadEntityRepositoryTest.kt b/api/src/test/kotlin/SimbadEntityRepositoryTest.kt new file mode 100644 index 000000000..04483ed4f --- /dev/null +++ b/api/src/test/kotlin/SimbadEntityRepositoryTest.kt @@ -0,0 +1,79 @@ +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.doubles.shouldBeExactly +import io.kotest.matchers.shouldBe +import io.objectbox.kotlin.boxFor +import nebulosa.api.atlas.SimbadEntity +import nebulosa.api.atlas.SimbadEntityRepository +import nebulosa.api.database.MyObjectBox +import nebulosa.math.Angle +import nebulosa.math.deg +import nebulosa.math.hours +import nebulosa.nova.astrometry.Constellation +import nebulosa.skycatalog.SkyObjectType +import java.util.* + +class SimbadEntityRepositoryTest : StringSpec() { + + init { + val boxStore = MyObjectBox.builder() + .inMemory(UUID.randomUUID().toString()) + .build() + + afterSpec { + boxStore.close() + } + + val box = boxStore.boxFor() + val repository = SimbadEntityRepository(box) + + repository.save("Sirius", SkyObjectType.STAR, Constellation.CMA, -1.45, "06 45 06".hours, "-16 43 33".deg) + repository.save("Dolphin Nebula", SkyObjectType.NEBULA, Constellation.CMA, 6.91, "06 54 11".hours, "-23 55 47".deg) + repository.save("75 Tucanae", SkyObjectType.GLOBULAR_CLUSTER, Constellation.TUC, 6.58, "01 03 12".hours, "-70 50 39".deg) + repository.save("Car Nebula", SkyObjectType.NEBULA, Constellation.CAR, 5.0, "10 45 15".hours, "-59 43 35".deg) + + "find all" { + repository.find().shouldHaveSize(4).first().magnitude shouldBeExactly -1.45 + } + "find by name" { + repository.find(name = "dolphin").shouldHaveSize(1).first().name shouldBe "Dolphin Nebula" + repository.find(name = "andromeda").shouldBeEmpty() + repository.find(name = "nebula").shouldHaveSize(2).first().magnitude shouldBeExactly 5.0 + } + "find by constellation" { + repository.find(constellation = Constellation.CMA).shouldHaveSize(2).first().magnitude shouldBeExactly -1.45 + repository.find(constellation = Constellation.AND).shouldBeEmpty() + } + "find by region" { + repository.find(rightAscension = "06 45 59".hours, declination = "-20 45 29".deg, radius = 4.5.deg).shouldHaveSize(2) + .first().magnitude shouldBeExactly -1.45 + repository.find(rightAscension = "06 45 59".hours, declination = "-20 45 29".deg, radius = 4.0.deg).shouldHaveSize(1) + .first().name shouldBe "Dolphin Nebula" + repository.find(rightAscension = "00 42 43".hours, declination = "41 15 53".deg, radius = 10.deg).shouldBeEmpty() + } + "find by magnitude" { + repository.find(magnitudeMin = 5.0).shouldHaveSize(3) + repository.find(magnitudeMax = 4.9).shouldHaveSize(1).first().name shouldBe "Sirius" + repository.find(magnitudeMin = 6.6, magnitudeMax = 6.99).shouldHaveSize(1).first().name shouldBe "Dolphin Nebula" + repository.find(magnitudeMax = -2.0).shouldBeEmpty() + repository.find(magnitudeMin = 7.0).shouldBeEmpty() + repository.find(magnitudeMin = 5.1, magnitudeMax = 6.0).shouldBeEmpty() + } + "find by type" { + repository.find(type = SkyObjectType.NEBULA).shouldHaveSize(2).first().magnitude shouldBeExactly 5.0 + repository.find(type = SkyObjectType.GALAXY).shouldBeEmpty() + } + } + + companion object { + + @JvmStatic + internal fun SimbadEntityRepository.save( + name: String, type: SkyObjectType, constellation: Constellation, + magnitude: Double, rightAscension: Angle, declination: Angle, + ) { + save(SimbadEntity(0L, name, type, rightAscension, declination, magnitude, constellation = constellation)) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 686ead046..bc5b13abd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ buildscript { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.0") classpath("org.jetbrains.kotlin:kotlin-allopen:2.0.0") classpath("com.adarshr:gradle-test-logger-plugin:4.0.0") - classpath("io.objectbox:objectbox-gradle-plugin:4.0.0") + classpath("io.objectbox:objectbox-gradle-plugin:4.0.1") } repositories { diff --git a/data/.gitignore b/data/.gitignore index b39285bd2..d48badef8 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -2,3 +2,4 @@ simbad/ astrobin/ test/ captures/ +siril/ diff --git a/desktop/camera.png b/desktop/camera.png index 8487d530d..c25ce3252 100644 Binary files a/desktop/camera.png and b/desktop/camera.png differ diff --git a/desktop/guider.png b/desktop/guider.png index 6c2bf9829..e37d31d52 100644 Binary files a/desktop/guider.png and b/desktop/guider.png differ diff --git a/desktop/indi.png b/desktop/indi.png index 14853441d..13db4d97e 100644 Binary files a/desktop/indi.png and b/desktop/indi.png differ diff --git a/desktop/package.json b/desktop/package.json index 0466564d7..4bd615f44 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -14,7 +14,7 @@ "postinstall": "electron-builder install-app-deps", "ng": "ng", "start": "npm-run-all -p electron:serve ng:serve", - "ng:serve": "ng serve -c web", + "ng:serve": "ng serve -c web --hmr", "build": "npm run electron:serve-tsc && ng build --base-href ./", "build:dev": "npm run build -- -c dev", "build:prod": "npm run build -- -c production", diff --git a/desktop/sequencer.png b/desktop/sequencer.png index a3926c583..679d27ebc 100644 Binary files a/desktop/sequencer.png and b/desktop/sequencer.png differ diff --git a/desktop/settings.png b/desktop/settings.png index ee6099d48..80e5fde64 100644 Binary files a/desktop/settings.png and b/desktop/settings.png differ diff --git a/desktop/src/app/alignment/alignment.component.html b/desktop/src/app/alignment/alignment.component.html index 3acb4adf8..fcdc2d66b 100644 --- a/desktop/src/app/alignment/alignment.component.html +++ b/desktop/src/app/alignment/alignment.component.html @@ -83,15 +83,15 @@
@if (pausingOrPaused) { + severity="success" size="small" [text]="true" /> } @else if(!running) { + label="Start" (onClick)="tppaStart()" icon="mdi mdi-play" severity="success" size="small" [text]="true" /> } - + + icon="mdi mdi-stop" severity="danger" size="small" [text]="true" />
@@ -124,15 +124,15 @@
+ icon="mdi mdi-play" severity="success" size="small" [text]="true" /> + icon="mdi mdi-stop" severity="danger" size="small" [text]="true" /> + styleClass="ml-4" pTooltip="View image" tooltipPosition="bottom" size="small" [text]="true" />
-
+
1. Locate a star near the south meridian and close to declination 0. 2. Start DARV and wait for routine to complete. 3. If you see V shaped track, adjust the Azimuth and repeat the step 2 till you get a line. diff --git a/desktop/src/app/alignment/alignment.component.ts b/desktop/src/app/alignment/alignment.component.ts index ad4ceb05f..4aa54e0ce 100644 --- a/desktop/src/app/alignment/alignment.component.ts +++ b/desktop/src/app/alignment/alignment.component.ts @@ -10,7 +10,7 @@ import { Angle } from '../../shared/types/atlas.types' import { Camera, EMPTY_CAMERA, EMPTY_CAMERA_START_CAPTURE, ExposureTimeUnit, updateCameraStartCaptureFromCamera } from '../../shared/types/camera.types' import { EMPTY_GUIDE_OUTPUT, GuideDirection, GuideOutput } from '../../shared/types/guider.types' import { EMPTY_MOUNT, Mount } from '../../shared/types/mount.types' -import { EMPTY_PLATE_SOLVER_OPTIONS } from '../../shared/types/settings.types' +import { EMPTY_PLATE_SOLVER_REQUEST } from '../../shared/types/settings.types' import { deviceComparator } from '../../shared/utils/comparators' import { AppComponent } from '../app.component' import { CameraComponent } from '../camera/camera.component' @@ -40,7 +40,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy, Pingable { readonly tppaRequest: TPPAStart = { capture: structuredClone(EMPTY_CAMERA_START_CAPTURE), - plateSolver: structuredClone(EMPTY_PLATE_SOLVER_OPTIONS), + plateSolver: structuredClone(EMPTY_PLATE_SOLVER_REQUEST), startFromCurrentPosition: true, stepDirection: 'EAST', compensateRefraction: true, @@ -224,11 +224,11 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy, Pingable { }) this.loadPreference() - - pinger.register(this, 30000) } async ngAfterViewInit() { + this.pinger.register(this, 30000) + this.cameras = (await this.api.cameras()).sort(deviceComparator) this.mounts = (await this.api.mounts()).sort(deviceComparator) this.guideOutputs = (await this.api.guideOutputs()).sort(deviceComparator) @@ -294,7 +294,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy, Pingable { } plateSolverChanged() { - this.tppaRequest.plateSolver = this.preference.plateSolverOptions(this.tppaRequest.plateSolver.type).get() + this.tppaRequest.plateSolver = this.preference.plateSolverRequest(this.tppaRequest.plateSolver.type).get() this.savePreference() } diff --git a/desktop/src/app/app.component.html b/desktop/src/app/app.component.html index d6c47560e..37f7d7c2c 100644 --- a/desktop/src/app/app.component.html +++ b/desktop/src/app/app.component.html @@ -6,9 +6,16 @@ + @if (e.toggleable) { +
+ {{ e.label }} + +
+ } @else { + }
diff --git a/desktop/src/app/app.module.ts b/desktop/src/app/app.module.ts index dd8e85990..f0cd52ca6 100644 --- a/desktop/src/app/app.module.ts +++ b/desktop/src/app/app.module.ts @@ -49,6 +49,7 @@ import { HistogramComponent } from '../shared/components/histogram/histogram.com import { MapComponent } from '../shared/components/map/map.component' import { MenuItemComponent } from '../shared/components/menu-item/menu-item.component' import { MoonComponent } from '../shared/components/moon/moon.component' +import { PathChooserComponent } from '../shared/components/path-chooser/path-chooser.component' import { SlideMenuComponent } from '../shared/components/slide-menu/slide-menu.component' import { LocationDialog } from '../shared/dialogs/location/location.dialog' import { ScrollableNumberDirective } from '../shared/directives/input-number-scrollable' @@ -56,6 +57,7 @@ import { NoDropdownDirective } from '../shared/directives/no-dropdown.directive' import { StopPropagationDirective } from '../shared/directives/stop-propagation.directive' import { LocationInterceptor } from '../shared/interceptors/location.interceptor' import { AnglePipe } from '../shared/pipes/angle.pipe' +import { DropdownOptionsPipe } from '../shared/pipes/dropdown-options' import { EnumPipe } from '../shared/pipes/enum.pipe' import { EnvPipe } from '../shared/pipes/env.pipe' import { ExposureTimePipe } from '../shared/pipes/exposureTime.pipe' @@ -100,6 +102,7 @@ import { SettingsComponent } from './settings/settings.component' DeviceChooserComponent, DeviceListMenuComponent, DialogMenuComponent, + DropdownOptionsPipe, EnumPipe, EnvPipe, ExposureTimePipe, @@ -121,6 +124,7 @@ import { SettingsComponent } from './settings/settings.component' MoonComponent, MountComponent, NoDropdownDirective, + PathChooserComponent, RotatorComponent, SequencerComponent, SettingsComponent, @@ -177,6 +181,7 @@ import { SettingsComponent } from './settings/settings.component' AnglePipe, ConfirmationService, DialogService, + DropdownOptionsPipe, EnumPipe, EnvPipe, ExposureTimePipe, diff --git a/desktop/src/app/atlas/atlas.component.html b/desktop/src/app/atlas/atlas.component.html index db73c7c19..2cdfcb5fb 100644 --- a/desktop/src/app/atlas/atlas.component.html +++ b/desktop/src/app/atlas/atlas.component.html @@ -144,8 +144,10 @@
- - + +
@@ -187,9 +189,9 @@
+ size="small" severity="info" [text]="true" pTooltip="Search" tooltipPosition="bottom" /> + size="small" [text]="true" pTooltip="Filter" tooltipPosition="bottom" />
diff --git a/desktop/src/app/autofocus/autofocus.component.html b/desktop/src/app/autofocus/autofocus.component.html index 2e39c859f..bbad649d8 100644 --- a/desktop/src/app/autofocus/autofocus.component.html +++ b/desktop/src/app/autofocus/autofocus.component.html @@ -58,7 +58,7 @@
- @@ -66,8 +66,8 @@
- +
@@ -83,7 +83,7 @@
- @@ -120,9 +120,9 @@
+ icon="mdi mdi-play" severity="success" size="small" [text]="true" /> + icon="mdi mdi-stop" severity="danger" size="small" [text]="true" />
diff --git a/desktop/src/app/autofocus/autofocus.component.ts b/desktop/src/app/autofocus/autofocus.component.ts index cec4ab6ab..b3aa3b750 100644 --- a/desktop/src/app/autofocus/autofocus.component.ts +++ b/desktop/src/app/autofocus/autofocus.component.ts @@ -302,11 +302,11 @@ export class AutoFocusComponent implements AfterViewInit, OnDestroy, Pingable { }) this.loadPreference() - - pinger.register(this, 30000) } async ngAfterViewInit() { + this.pinger.register(this, 30000) + this.cameras = (await this.api.cameras()).sort(deviceComparator) this.focusers = (await this.api.focusers()).sort(deviceComparator) } @@ -355,7 +355,7 @@ export class AutoFocusComponent implements AfterViewInit, OnDestroy, Pingable { this.clearChart() this.stepSizeForScale = this.request.stepSize - this.request.starDetector = this.preference.starDetectionOptions('ASTAP').get() + this.request.starDetector = this.preference.starDetectionRequest('ASTAP').get() return this.api.autoFocusStart(this.camera, this.focuser, this.request) } diff --git a/desktop/src/app/camera/camera.component.html b/desktop/src/app/camera/camera.component.html index c082be6b0..0996011b7 100644 --- a/desktop/src/app/camera/camera.component.html +++ b/desktop/src/app/camera/camera.component.html @@ -64,7 +64,7 @@ + icon="mdi mdi-check" size="small" severity="success" pTooltip="Apply" tooltipPosition="bottom" [text]="true" />
@@ -156,7 +156,7 @@
+ severity="info" size="small" pTooltip="Full size" tooltipPosition="bottom" [text]="true" />
@@ -203,13 +203,20 @@
+ @if (pausingOrPaused) { + + } @else if(!running) { + tooltipStyleClass="min-w-22rem flex justify-content-center" [text]="true" /> + } + + severity="danger" size="small" [text]="true" /> + severity="info" size="small" [text]="true" />
@@ -221,28 +228,70 @@
Enabled - +
RA only -
-
- + [(ngModel)]="request.dither.afterExposures" [step]="1" (ngModelChange)="savePreference()" scrollableNumber />
+ + + +
+
+ Enabled + +
+
+ + + + +
+
+ 32-bits (slower) + +
+
+ + + + +
+
+ +
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/desktop/src/app/camera/camera.component.ts b/desktop/src/app/camera/camera.component.ts index 2d883b3e4..fe3e1bd95 100644 --- a/desktop/src/app/camera/camera.component.ts +++ b/desktop/src/app/camera/camera.component.ts @@ -75,43 +75,57 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { } showDitherDialog = false + showLiveStackingDialog = false calibrationModel: MenuItem[] = [] - readonly cameraModel: MenuItem[] = [ - { - icon: 'icomoon random-dither', - label: 'Dither', - command: () => { - this.showDitherDialog = true - }, + private readonly ditherMenuItem: MenuItem = { + icon: 'icomoon random-dither', + label: 'Dither', + command: () => { + this.showDitherDialog = true }, - { - icon: 'mdi mdi-connection', - label: 'Snoop Devices', - subMenu: [ - { - icon: 'mdi mdi-telescope', - label: 'Mount', - subMenu: [], - }, - { - icon: 'mdi mdi-palette', - label: 'Filter Wheel', - subMenu: [], - }, - { - icon: 'mdi mdi-image-filter-center-focus', - label: 'Focuser', - subMenu: [], - }, - { - icon: 'mdi mdi-rotate-right', - label: 'Rotator', - subMenu: [], - }, - ] + } + + private readonly liveStackingMenuItem: MenuItem = { + icon: 'mdi mdi-image-multiple', + label: 'Live Stacking', + command: () => { + this.showLiveStackingDialog = true }, + } + + private readonly snoopDevicesMenuItem: MenuItem = { + icon: 'mdi mdi-connection', + label: 'Snoop Devices', + subMenu: [ + { + icon: 'mdi mdi-telescope', + label: 'Mount', + subMenu: [], + }, + { + icon: 'mdi mdi-palette', + label: 'Filter Wheel', + subMenu: [], + }, + { + icon: 'mdi mdi-image-filter-center-focus', + label: 'Focuser', + subMenu: [], + }, + { + icon: 'mdi mdi-rotate-right', + label: 'Rotator', + subMenu: [], + }, + ] + } + + readonly cameraModel: MenuItem[] = [ + this.ditherMenuItem, + this.liveStackingMenuItem, + this.snoopDevicesMenuItem, ] hasDewHeater = false @@ -162,6 +176,14 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { @ViewChild('cameraExposure') private readonly cameraExposure!: CameraExposureComponent + get status() { + return this.cameraExposure?.state ?? 'IDLE' + } + + get pausingOrPaused() { + return this.status === 'PAUSING' || this.status === 'PAUSED' + } + constructor( private app: AppComponent, private api: ApiService, @@ -246,27 +268,27 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { } }) - this.cameraModel[1].visible = !app.modal - - pinger.register(this, 30000) + this.snoopDevicesMenuItem.visible = !app.modal } ngAfterContentInit() { - this.route.queryParams.subscribe(e => { + this.route.queryParams.subscribe(async e => { const decodedData = JSON.parse(decodeURIComponent(e.data)) if (this.app.modal) { - this.loadCameraStartCaptureForDialogMode(decodedData) + await this.loadCameraStartCaptureForDialogMode(decodedData) } else { - this.cameraChanged(decodedData) + await this.cameraChanged(decodedData) } - }) - if (!this.app.modal) { - this.loadEquipment() - } + this.pinger.register(this, 30000) + + if (!this.app.modal) { + await this.loadEquipment() + } - this.loadCalibrationGroups() + await this.loadCalibrationGroups() + }) } @HostListener('window:unload') @@ -355,10 +377,10 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { return makeItem(this.equipment.mount?.name === mount?.name, () => this.equipment.mount = mount, mount) } - this.cameraModel[1].subMenu![0].subMenu!.push(makeMountItem()) + this.snoopDevicesMenuItem.subMenu![0].subMenu!.push(makeMountItem()) for (const mount of mounts) { - this.cameraModel[1].subMenu![0].subMenu!.push(makeMountItem(mount)) + this.snoopDevicesMenuItem.subMenu![0].subMenu!.push(makeMountItem(mount)) } // FILTER WHEEL @@ -370,10 +392,10 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { return makeItem(this.equipment.wheel?.name === wheel?.name, () => this.equipment.wheel = wheel, wheel) } - this.cameraModel[1].subMenu![1].subMenu!.push(makeWheelItem()) + this.snoopDevicesMenuItem.subMenu![1].subMenu!.push(makeWheelItem()) for (const wheel of wheels) { - this.cameraModel[1].subMenu![1].subMenu!.push(makeWheelItem(wheel)) + this.snoopDevicesMenuItem.subMenu![1].subMenu!.push(makeWheelItem(wheel)) } // FOCUSER @@ -385,10 +407,10 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { return makeItem(this.equipment.focuser?.name === focuser?.name, () => this.equipment.focuser = focuser, focuser) } - this.cameraModel[1].subMenu![2].subMenu!.push(makeFocuserItem()) + this.snoopDevicesMenuItem.subMenu![2].subMenu!.push(makeFocuserItem()) for (const focuser of focusers) { - this.cameraModel[1].subMenu![2].subMenu!.push(makeFocuserItem(focuser)) + this.snoopDevicesMenuItem.subMenu![2].subMenu!.push(makeFocuserItem(focuser)) } // ROTATOR @@ -400,10 +422,10 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { return makeItem(this.equipment.rotator?.name === rotator?.name, () => this.equipment.rotator = rotator, rotator) } - this.cameraModel[1].subMenu![3].subMenu!.push(makeRotatorItem()) + this.snoopDevicesMenuItem.subMenu![3].subMenu!.push(makeRotatorItem()) for (const rotator of rotators) { - this.cameraModel[1].subMenu![3].subMenu!.push(makeRotatorItem(rotator)) + this.snoopDevicesMenuItem.subMenu![3].subMenu!.push(makeRotatorItem(rotator)) } buildStartTooltip() @@ -424,6 +446,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { checked: this.request.calibrationGroup === name, command: () => { this.request.calibrationGroup = name + this.savePreference() this.loadCalibrationGroups() }, } @@ -518,6 +541,10 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { const exposureAmount = this.exposureMode === 'LOOP' ? 0 : (this.exposureMode === 'FIXED' ? this.request.exposureAmount : 1) const savePath = this.mode !== 'CAPTURE' ? this.request.savePath : this.savePath + const liveStackingRequest = this.preference.liveStackingRequest(this.request.liveStacking.type).get() + this.request.liveStacking.executablePath = liveStackingRequest.executablePath + this.request.liveStacking.slot = liveStackingRequest.slot || 1 + return { ...this.request, x, y, width, height, @@ -527,10 +554,23 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { } async startCapture() { - await this.openCameraImage() - await this.api.cameraSnoop(this.camera, this.equipment) - await this.api.cameraStartCapture(this.camera, this.makeCameraStartCapture()) - this.preference.equipmentForDevice(this.camera).set(this.equipment) + try { + this.running = true + await this.openCameraImage() + await this.api.cameraSnoop(this.camera, this.equipment) + await this.api.cameraStartCapture(this.camera, this.makeCameraStartCapture()) + this.preference.equipmentForDevice(this.camera).set(this.equipment) + } catch { + this.running = false + } + } + + pauseCapture() { + return this.api.cameraPauseCapture(this.camera) + } + + unpauseCapture() { + return this.api.cameraUnpauseCapture(this.camera) } abortCapture() { @@ -630,11 +670,15 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { this.request.gain = preference.gain ?? 0 this.request.offset = preference.offset ?? 0 this.request.frameFormat = preference.frameFormat ?? (this.camera.frameFormats[0] || '') + this.request.calibrationGroup = preference.calibrationGroup - this.request.dither!.enabled = preference.dither?.enabled ?? false - this.request.dither!.raOnly = preference.dither?.raOnly ?? false - this.request.dither!.amount = preference.dither?.amount ?? 1.5 - this.request.dither!.afterExposures = preference.dither?.afterExposures ?? 1 + if (preference.dither) { + Object.assign(this.request.dither, preference.dither) + } + + if (preference.liveStacking) { + Object.assign(this.request.liveStacking, preference.liveStacking) + } Object.assign(this.equipment, this.preference.equipmentForDevice(this.camera).get()) } diff --git a/desktop/src/app/filterwheel/filterwheel.component.html b/desktop/src/app/filterwheel/filterwheel.component.html index 9ae270e7d..eb8b6f42c 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.html +++ b/desktop/src/app/filterwheel/filterwheel.component.html @@ -47,7 +47,7 @@
+ (onClick)="moveToSelectedFilter()" size="small" [text]="true" />
diff --git a/desktop/src/app/filterwheel/filterwheel.component.ts b/desktop/src/app/filterwheel/filterwheel.component.ts index 18ee4ab06..9cbdf65e0 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.ts +++ b/desktop/src/app/filterwheel/filterwheel.component.ts @@ -134,22 +134,22 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy, Pingab hotkeys('7', event => { event.preventDefault(); this.moveToPosition(7) }) hotkeys('8', event => { event.preventDefault(); this.moveToPosition(8) }) hotkeys('9', event => { event.preventDefault(); this.moveToPosition(9) }) - - pinger.register(this, 30000) } async ngAfterContentInit() { - this.route.queryParams.subscribe(e => { + this.route.queryParams.subscribe(async e => { const decodedData = JSON.parse(decodeURIComponent(e.data)) if (this.app.modal) { const request = decodedData as WheelDialogInput Object.assign(this.request, request.request) this.mode = request.mode - this.wheelChanged(request.wheel) + await this.wheelChanged(request.wheel) } else { - this.wheelChanged(decodedData) + await this.wheelChanged(decodedData) } + + this.pinger.register(this, 30000) }) this.focusers = await this.api.focusers() diff --git a/desktop/src/app/flat-wizard/flat-wizard.component.html b/desktop/src/app/flat-wizard/flat-wizard.component.html index 9c6bf6135..8fb92158c 100644 --- a/desktop/src/app/flat-wizard/flat-wizard.component.html +++ b/desktop/src/app/flat-wizard/flat-wizard.component.html @@ -67,9 +67,9 @@
+ severity="success" size="small" [text]="true" /> + severity="danger" size="small" [text]="true" />
\ No newline at end of file diff --git a/desktop/src/app/flat-wizard/flat-wizard.component.ts b/desktop/src/app/flat-wizard/flat-wizard.component.ts index 41a887d19..fd02d6764 100644 --- a/desktop/src/app/flat-wizard/flat-wizard.component.ts +++ b/desktop/src/app/flat-wizard/flat-wizard.component.ts @@ -143,11 +143,11 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy, Pingable { } }) }) - - pinger.register(this, 30000) } async ngAfterViewInit() { + this.pinger.register(this, 30000) + this.cameras = (await this.api.cameras()).sort(deviceComparator) this.wheels = (await this.api.wheels()).sort(deviceComparator) } diff --git a/desktop/src/app/focuser/focuser.component.ts b/desktop/src/app/focuser/focuser.component.ts index 1c1221264..c069d991f 100644 --- a/desktop/src/app/focuser/focuser.component.ts +++ b/desktop/src/app/focuser/focuser.component.ts @@ -61,14 +61,13 @@ export class FocuserComponent implements AfterViewInit, OnDestroy, Pingable { hotkeys('down', event => { event.preventDefault(); this.stepsRelative = Math.max(0, this.stepsRelative - 1) }) hotkeys('ctrl+up', event => { event.preventDefault(); this.stepsAbsolute = Math.max(0, this.stepsAbsolute - 1) }) hotkeys('ctrl+down', event => { event.preventDefault(); this.stepsAbsolute = Math.min(this.focuser.maxPosition, this.stepsAbsolute + 1) }) - - pinger.register(this, 30000) } async ngAfterViewInit() { - this.route.queryParams.subscribe(e => { + this.route.queryParams.subscribe(async e => { const focuser = JSON.parse(decodeURIComponent(e.data)) as Focuser - this.focuserChanged(focuser) + await this.focuserChanged(focuser) + this.pinger.register(this, 30000) }) } diff --git a/desktop/src/app/guider/guider.component.html b/desktop/src/app/guider/guider.component.html index afdf8c25b..ad692a1fe 100644 --- a/desktop/src/app/guider/guider.component.html +++ b/desktop/src/app/guider/guider.component.html @@ -1,8 +1,8 @@ -
+
-
+
- + + size="small" severity="info" [text]="true" />
-
-
{{ guideState | enum | lowercase }} {{ message }}
-
- - -
-
- - - - -
-
- - - - -
-
- - - - -
-
- - - - -
-
- - - - -
-
- - - - -
-
- - - - -
-
- - - - -
-
-
-
- -
- North - East -
-
- -
-
- - - - -
-
- - - - -
-
- - - - -
-
-
-
-
- - - -
+
+ + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+
+
+ +
+ North + East +
+
+ +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+
+
+
+ + + +
+
+
- +
-
- -
-
+
+
+ +
diff --git a/desktop/src/app/guider/guider.component.ts b/desktop/src/app/guider/guider.component.ts index ac16de440..318fb1a39 100644 --- a/desktop/src/app/guider/guider.component.ts +++ b/desktop/src/app/guider/guider.component.ts @@ -70,7 +70,7 @@ export class GuiderComponent implements AfterViewInit, OnDestroy, Pingable { } readonly chartData: ChartData = { - labels: Array.from({ length: 100 }), + labels: Array.from({ length: 100 }, (_, i) => `${i}`), datasets: [ // RA. { @@ -129,9 +129,8 @@ export class GuiderComponent implements AfterViewInit, OnDestroy, Pingable { 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) + const formattedY = prefix + (barType ? y.toFixed(0) + ' ms' : y.toFixed(2) + lineSuffix) return formattedY } } @@ -188,7 +187,8 @@ export class GuiderComponent implements AfterViewInit, OnDestroy, Pingable { } }, x: { - stacked: false, + type: 'linear', + stacked: true, min: 0, max: 100, border: { @@ -196,13 +196,20 @@ export class GuiderComponent implements AfterViewInit, OnDestroy, Pingable { dash: [2, 4], }, ticks: { - autoSkip: true, + autoSkip: false, count: 11, maxRotation: 0, minRotation: 0, - callback: (value) => { + callback: (value, i, ticks) => { const a = value as number - return (a - Math.trunc(a) > 0) ? undefined : a.toFixed(0) + + if (i === 0) { + return a.toFixed(0) + } else if (ticks[i - 1]) { + if (Math.abs(Math.trunc(ticks[i - 1].value) - Math.trunc(a)) >= 1) { + return a.toFixed(0) + } + } } }, grid: { @@ -285,11 +292,11 @@ export class GuiderComponent implements AfterViewInit, OnDestroy, Pingable { this.message = event.data }) }) - - pinger.register(this, 30000) } async ngAfterViewInit() { + this.pinger.register(this, 30000) + const settle = await this.api.getGuidingSettle() this.settleAmount = settle.amount ?? 1.5 diff --git a/desktop/src/app/image/image.component.html b/desktop/src/app/image/image.component.html index ef6ee2804..f24ba2d51 100644 --- a/desktop/src/app/image/image.component.html +++ b/desktop/src/app/image/image.component.html @@ -10,9 +10,9 @@ - - + {{ (a.star ?? a.dso ?? a.minorPlanet) | skyObject:'name' }} @@ -85,71 +85,76 @@
- + -
- +
- +
-
-
-
+
-
-
+
- +
-
+
- +
-
+
-
-
- Simbad +
+ Simbad
- - - - + + + +
@@ -159,34 +164,53 @@
- + - +
- +
- +
-
+
- +
+ @if (solver.type === 'SIRIL') { +
+ + + + +
+
+ + + + +
+ }
@@ -230,18 +254,19 @@
- - - -
- +
@@ -295,7 +320,7 @@
+ [options]="'SCNR_PROTECTION_METHOD' | dropdownOptions" styleClass="p-inputtext-sm border-0" [autoDisplayFirst]="false">
{{ item | enum }} @@ -421,18 +446,25 @@
-
-
+
+
+ + + + +
COMPUTED
@@ -457,7 +489,7 @@
+ value="{{ starDetection.computed.minFlux.toFixed(0) }} | {{ starDetection.computed.maxFlux.toFixed(0) }}" />
@@ -478,7 +510,7 @@
- +
@@ -490,7 +522,7 @@
- +
@@ -498,7 +530,7 @@
- + @@ -673,13 +705,13 @@
-
diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 4a8dbeaff..742f6bc7f 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -16,9 +16,9 @@ import { BrowserWindowService } from '../../shared/services/browser-window.servi import { ElectronService } from '../../shared/services/electron.service' import { PreferenceService } from '../../shared/services/preference.service' import { PrimeService } from '../../shared/services/prime.service' -import { Angle, AstronomicalObject, DeepSkyObject, EquatorialCoordinateJ2000, Star } from '../../shared/types/atlas.types' +import { Angle, EquatorialCoordinateJ2000 } from '../../shared/types/atlas.types' import { Camera } from '../../shared/types/camera.types' -import { DEFAULT_FOV, DetectedStar, EMPTY_IMAGE_SOLVED, FOV, IMAGE_STATISTICS_BIT_OPTIONS, ImageAnnotation, ImageAnnotationDialog, ImageChannel, ImageData, ImageFITSHeadersDialog, ImageFOVDialog, ImageInfo, ImageROI, ImageSCNRDialog, ImageSaveDialog, ImageSolved, ImageSolverDialog, ImageStatisticsBitOption, ImageStretchDialog, ImageTransformation, SCNR_PROTECTION_METHODS, StarDetectionDialog } from '../../shared/types/image.types' +import { AnnotationInfoDialog, DEFAULT_FOV, DetectedStar, EMPTY_IMAGE_SOLVED, FITSHeaderItem, FOV, IMAGE_STATISTICS_BIT_OPTIONS, ImageAnnotation, ImageAnnotationDialog, ImageChannel, ImageData, ImageFITSHeadersDialog, ImageFOVDialog, ImageInfo, ImageROI, ImageSCNRDialog, ImageSaveDialog, ImageSolved, ImageSolverDialog, ImageStatisticsBitOption, ImageStretchDialog, ImageTransformation, StarDetectionDialog } from '../../shared/types/image.types' import { Mount } from '../../shared/types/mount.types' import { CoordinateInterpolator, InterpolatedCoordinate } from '../../shared/utils/coordinate-interpolation' import { AppComponent } from '../app.component' @@ -51,6 +51,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { imageInfo?: ImageInfo private imageURL!: string imageData: ImageData = {} + showLiveStackedImage?: boolean readonly scnrChannels: { name: string, value?: ImageChannel }[] = [ { name: 'None', value: undefined }, @@ -58,7 +59,6 @@ export class ImageComponent implements AfterViewInit, OnDestroy { { name: 'Green', value: 'GREEN' }, { name: 'Blue', value: 'BLUE' }, ] - readonly scnrMethods = Array.from(SCNR_PROTECTION_METHODS) readonly scnr: ImageSCNRDialog = { showDialog: false, amount: 0.5, @@ -92,16 +92,25 @@ export class ImageComponent implements AfterViewInit, OnDestroy { readonly annotation: ImageAnnotationDialog = { showDialog: false, + running: false, + visible: false, useStarsAndDSOs: true, useMinorPlanets: false, minorPlanetsMagLimit: 18.0, - useSimbad: false + useSimbad: false, + data: [] + } + + readonly annotationInfo: AnnotationInfoDialog = { + showDialog: false } readonly starDetection: StarDetectionDialog = { showDialog: false, + running: false, type: 'ASTAP', minSNR: 0, + maxStars: 0, visible: false, stars: [], computed: { @@ -121,22 +130,18 @@ export class ImageComponent implements AfterViewInit, OnDestroy { readonly solver: ImageSolverDialog = { showDialog: false, - solving: false, + running: false, + type: 'ASTAP', blind: true, centerRA: '', centerDEC: '', radius: 4, + focalLength: 0, + pixelSize: 0, solved: structuredClone(EMPTY_IMAGE_SOLVED), - types: ['ASTAP', 'ASTROMETRY_NET_ONLINE'], - type: 'ASTAP' } crossHair = false - annotations: ImageAnnotation[] = [] - annotating = false - showAnnotationInfoDialog = false - annotationInfo?: AstronomicalObject & Partial - annotationIsVisible = false readonly fitsHeaders: ImageFITSHeadersDialog = { showDialog: false, @@ -303,7 +308,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { disabled: true, command: () => { this.executeMount(mount => { - this.api.pointMountHere(mount, this.imageData.path!, this.imageMouseX, this.imageMouseY) + this.api.pointMountHere(mount, this.imagePath!, this.imageMouseX, this.imageMouseY) }) }, } @@ -341,7 +346,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, toggle: (event) => { event.originalEvent?.stopImmediatePropagation() - this.annotationIsVisible = event.checked + this.annotation.visible = event.checked }, } @@ -453,6 +458,14 @@ export class ImageComponent implements AfterViewInit, OnDestroy { && !this.transformation.mirrorVertical } + get imagePath() { + return (this.showLiveStackedImage && this.imageData.liveStackedPath) || this.imageData.path + } + + get canPlateSolve() { + return this.solver.type !== 'SIRIL' || (this.solver.focalLength > 0 && this.solver.pixelSize > 0) + } + constructor( private app: AppComponent, private route: ActivatedRoute, @@ -465,6 +478,23 @@ export class ImageComponent implements AfterViewInit, OnDestroy { ) { app.title = 'Image' + app.topMenu.push({ + label: 'Live Stacking', + toggleable: true, + visible: false, + toggle: (event) => { + if (event.originalEvent) { + this.showLiveStackedImage = !!event.checked + + if (this.showLiveStackedImage) { + this.disableCalibration(true) + } + + this.loadImage(true) + } + }, + }) + app.topMenu.push({ icon: 'mdi mdi-fullscreen', label: 'Fullscreen', @@ -509,19 +539,32 @@ export class ImageComponent implements AfterViewInit, OnDestroy { electron.on('CAMERA.CAPTURE_ELAPSED', async (event) => { if (event.state === 'EXPOSURE_FINISHED' && event.camera.id === this.imageData.camera?.id) { - await this.closeImage(true) - ngZone.run(() => { - this.imageData.path = event.savePath + if (this.showLiveStackedImage === undefined) { + if (event.liveStackedPath) { + this.showLiveStackedImage = true + this.app.topMenu[0].toggled = true + this.app.topMenu[0].visible = true + this.disableCalibration(true) + } + } else if (!event.liveStackedPath) { + this.showLiveStackedImage = undefined + this.app.topMenu[0].toggled = false + this.app.topMenu[0].visible = false + } + + this.imageData.path = event.savedPath + this.imageData.liveStackedPath = event.liveStackedPath + this.imageData.capture = event.capture + this.imageData.exposureCount = event.exposureCount + this.clearOverlay() - this.loadImage() + this.loadImage(true) }) } }) electron.on('DATA.CHANGED', async (event: ImageData) => { - await this.closeImage(event.path !== this.imageData.path) - ngZone.run(() => { this.loadImageFromData(event) }) @@ -563,12 +606,12 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } private markCalibrationGroupItem(name?: string) { - this.calibrationMenuItem.items![1].checked = this.calibrationViaCamera + this.calibrationMenuItem.items![2].disabled = !this.imageInfo?.camera?.id + this.calibrationMenuItem.items![2].checked = this.calibrationViaCamera for (let i = 3; i < this.calibrationMenuItem.items!.length; i++) { const item = this.calibrationMenuItem.items![i] - item.checked = item.label === (name ?? 'None') - item.disabled = this.calibrationViaCamera + item.checked = !this.calibrationViaCamera && item.data === name } } @@ -589,11 +632,12 @@ export class ImageComponent implements AfterViewInit, OnDestroy { return { label, icon, - checked: this.transformation.calibrationGroup === name, - disabled: this.calibrationViaCamera, + checked: !this.calibrationViaCamera && this.transformation.calibrationGroup === name, + data: name, command: async () => { + this.calibrationViaCamera = false this.transformation.calibrationGroup = name - this.markCalibrationGroupItem(label) + this.markCalibrationGroupItem(name) await this.loadImage() }, } @@ -607,17 +651,23 @@ export class ImageComponent implements AfterViewInit, OnDestroy { command: () => this.browserWindow.openCalibration() }) + menu.push(SEPARATOR_MENU_ITEM) + menu.push({ label: 'Camera', icon: 'mdi mdi-camera-iris', checked: this.calibrationViaCamera, - command: () => { - this.calibrationViaCamera = !this.calibrationViaCamera - this.markCalibrationGroupItem(this.transformation.calibrationGroup) + disabled: !this.imageInfo?.camera?.id, + data: 0, + command: async () => { + if (this.imageInfo?.camera?.id) { + this.calibrationViaCamera = !this.calibrationViaCamera + this.markCalibrationGroupItem(this.transformation.calibrationGroup) + await this.loadImage() + } } }) - menu.push(SEPARATOR_MENU_ITEM) menu.push(makeItem()) for (const group of groups) { @@ -634,10 +684,11 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } private async closeImage(force: boolean = false) { - if (this.imageData.path) { - if (force) { - await this.api.closeImage(this.imageData.path) - } + if (this.imageData.path && force) { + await this.api.closeImage(this.imageData.path) + } + if (this.imageData.liveStackedPath && force) { + await this.api.closeImage(this.imageData.liveStackedPath) } } @@ -720,12 +771,12 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } this.clearOverlay() - this.loadImage() + this.loadImage(true) } private clearOverlay() { - this.annotations = [] - this.annotationIsVisible = false + this.annotation.data = [] + this.annotation.visible = false this.annotationMenuItem.toggleable = false this.starDetection.stars = [] @@ -738,7 +789,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } private async computeHistogram() { - const data = await this.api.imageHistogram(this.imageData.path!, this.statisticsBitLength.bitLength) + const data = await this.api.imageHistogram(this.imagePath!, this.statisticsBitLength.bitLength) this.histogram.update(data) } @@ -747,9 +798,16 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } async detectStars() { - const options = this.preference.starDetectionOptions(this.starDetection.type).get() + const options = this.preference.starDetectionRequest(this.starDetection.type).get() options.minSNR = this.starDetection.minSNR - this.starDetection.stars = await this.api.detectStars(this.imageData.path!, options) + options.maxStars = this.starDetection.maxStars + + try { + this.starDetection.running = true + this.starDetection.stars = await this.api.detectStars(this.imagePath!, options) + } finally { + this.starDetection.running = false + } let hfd = 0 let snr = 0 @@ -784,9 +842,19 @@ export class ImageComponent implements AfterViewInit, OnDestroy { ctx?.drawImage(this.image.nativeElement, star.x - 8, star.y - 8, 16, 16, 0, 0, canvas.width, canvas.height) } - private async loadImage() { - if (this.imageData.path) { - await this.loadImageFromPath(this.imageData.path) + private async loadImage(force: boolean = false) { + await this.closeImage(force) + + const path = this.imagePath + + if (path) { + await this.loadImageFromPath(path) + } + + let extraInfo = '' + + if (this.imageData.exposureCount) { + extraInfo += ` · ${this.imageData.exposureCount}` } if (this.imageData.title) { @@ -798,13 +866,15 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } else { this.app.subTitle = '' } + + this.app.subTitle += extraInfo } private async loadImageFromPath(path: string) { const image = this.image.nativeElement const transformation = structuredClone(this.transformation) - if (this.calibrationViaCamera) transformation.calibrationGroup = this.imageData.capture?.calibrationGroup + if (this.calibrationViaCamera && !this.showLiveStackedImage) transformation.calibrationGroup = this.imageData.capture?.calibrationGroup const { info, blob } = await this.api.openImage(path, transformation, this.imageData.camera) this.imageInfo = info @@ -824,6 +894,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.fitsHeaders.headers = info.headers + this.retrieveInfoFromImageHeaders(info.headers) + if (this.imageURL) window.URL.revokeObjectURL(this.imageURL) this.imageURL = window.URL.createObjectURL(blob) image.src = this.imageURL @@ -831,11 +903,28 @@ export class ImageComponent implements AfterViewInit, OnDestroy { if (!info.camera?.id) { this.calibrationViaCamera = false this.markCalibrationGroupItem(this.transformation.calibrationGroup) + } else { + this.calibrationMenuItem.items![2].disabled = false } this.retrieveCoordinateInterpolation() } + private retrieveInfoFromImageHeaders(headers: FITSHeaderItem[]) { + const imagePreference = this.preference.imagePreference.get() + + for (const item of headers) { + if (item.name === 'FOCALLEN') { + this.solver.focalLength = parseFloat(item.value) + } else if (item.name === 'XPIXSZ') { + this.solver.pixelSize = parseFloat(item.value) + } + } + + this.solver.focalLength ||= imagePreference.solver?.focalLength || 0 + this.solver.pixelSize ||= imagePreference.solver?.pixelSize || 0 + } + imageClicked(event: MouseEvent, contextMenu: boolean) { this.imageMouseX = event.offsetX this.imageMouseY = event.offsetY @@ -861,27 +950,27 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } async saveImageAs() { - await this.api.saveImageAs(this.imageData!.path!, this.saveAs, this.imageData.camera) + await this.api.saveImageAs(this.imagePath!, this.saveAs, this.imageData.camera) this.saveAs.showDialog = false } async annotateImage() { try { - this.annotating = true - this.annotations = await this.api.annotationsOfImage(this.imageData.path!, this.annotation.useStarsAndDSOs, + this.annotation.running = true + this.annotation.data = await this.api.annotationsOfImage(this.imagePath!, this.annotation.useStarsAndDSOs, this.annotation.useMinorPlanets, this.annotation.minorPlanetsMagLimit, this.annotation.useSimbad) - this.annotationIsVisible = true - this.annotationMenuItem.toggleable = this.annotations.length > 0 - this.annotationMenuItem.toggled = this.annotationMenuItem.toggleable + this.annotation.visible = this.annotation.data.length > 0 + this.annotationMenuItem.toggleable = this.annotation.visible + this.annotationMenuItem.toggled = this.annotation.visible this.annotation.showDialog = false } finally { - this.annotating = false + this.annotation.running = false } } showAnnotationInfo(annotation: ImageAnnotation) { - this.annotationInfo = annotation.star ?? annotation.dso ?? annotation.minorPlanet - this.showAnnotationInfoDialog = true + this.annotationInfo.info = annotation.star ?? annotation.dso ?? annotation.minorPlanet + this.annotationInfo.showDialog = true } private disableAutoStretch() { @@ -983,7 +1072,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } private async retrieveCoordinateInterpolation() { - const coordinate = await this.api.coordinateInterpolation(this.imageData.path!) + const coordinate = await this.api.coordinateInterpolation(this.imagePath!) if (coordinate) { const { ma, md, x0, y0, x1, y1, delta } = coordinate @@ -998,11 +1087,11 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } async solveImage() { - this.solver.solving = true + this.solver.running = true try { - const solver = this.preference.plateSolverOptions(this.solver.type).get() - const solved = await this.api.solveImage(solver, this.imageData.path!, this.solver.blind, + const solver = this.preference.plateSolverRequest(this.solver.type).get() + const solved = await this.api.solveImage(solver, this.imagePath!, this.solver.blind, this.solver.centerRA, this.solver.centerDEC, this.solver.radius) this.savePreference() @@ -1010,7 +1099,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } catch { this.updateImageSolved(this.imageInfo?.solved) } finally { - this.solver.solving = false + this.solver.running = false if (this.solver.solved.solved) { this.retrieveCoordinateInterpolation() @@ -1186,10 +1275,13 @@ export class ImageComponent implements AfterViewInit, OnDestroy { private loadPreference() { const preference = this.preference.imagePreference.get() - this.solver.radius = preference.solverRadius ?? this.solver.radius - this.solver.type = preference.solverType ?? this.solver.types[0] - this.starDetection.type = preference.starDetectionType ?? this.starDetection.type - this.starDetection.minSNR = this.preference.starDetectionOptions(this.starDetection.type).get().minSNR ?? this.starDetection.type + this.solver.radius = preference.solver?.radius ?? this.solver.radius + this.solver.type = preference.solver?.type ?? 'ASTAP' + this.solver.focalLength = preference.solver?.focalLength ?? 0 + this.solver.pixelSize = preference.solver?.pixelSize ?? 0 + this.starDetection.type = preference.starDetection?.type ?? this.starDetection.type + this.starDetection.minSNR = preference.starDetection?.minSNR ?? this.preference.starDetectionRequest(this.starDetection.type).get().minSNR ?? this.starDetection.minSNR + this.starDetection.maxStars = preference.starDetection?.maxStars ?? this.preference.starDetectionRequest(this.starDetection.type).get().maxStars ?? this.starDetection.maxStars this.fov.fovs = this.preference.imageFOVs.get() this.fov.fovs.forEach(e => { e.enabled = false; e.computed = undefined }) @@ -1197,9 +1289,19 @@ export class ImageComponent implements AfterViewInit, OnDestroy { private savePreference() { const preference = this.preference.imagePreference.get() - preference.solverRadius = this.solver.radius - preference.solverType = this.solver.type - preference.starDetectionType = this.starDetection.type + + preference.solver = { + type: this.solver.type, + focalLength: this.solver.focalLength, + pixelSize: this.solver.pixelSize, + radius: this.solver.radius, + } + preference.starDetection = { + type: this.starDetection.type, + maxStars: this.starDetection.maxStars, + minSNR: this.starDetection.minSNR, + } + this.preference.imagePreference.set(preference) } diff --git a/desktop/src/app/indi/property/indi-property.component.html b/desktop/src/app/indi/property/indi-property.component.html index 7b5c9c3f6..84098b176 100644 --- a/desktop/src/app/indi/property/indi-property.component.html +++ b/desktop/src/app/indi/property/indi-property.component.html @@ -6,17 +6,17 @@
+ (onClick)="sendSwitch(item)" icon="pi" [severity]="item.value ? 'success' : 'danger'" size="small" [text]="true">
+ (onClick)="sendSwitch(item)" icon="mdi mdi-check" size="small" [text]="true" />
+ (onClick)="sendSwitch(item)" icon="pi" [severity]="item.value ? 'success' : 'danger'" size="small" [text]="true">
@@ -39,7 +39,7 @@
+ size="small" [text]="true" />
@@ -60,7 +60,8 @@
- +
diff --git a/desktop/src/app/mount/mount.component.html b/desktop/src/app/mount/mount.component.html index 471ac72fc..a21d943c1 100644 --- a/desktop/src/app/mount/mount.component.html +++ b/desktop/src/app/mount/mount.component.html @@ -226,7 +226,7 @@
- @@ -244,6 +244,12 @@
+
+ Used with the mobile app + Used with the desktop app +
diff --git a/desktop/src/app/mount/mount.component.ts b/desktop/src/app/mount/mount.component.ts index 604dd02de..fe4fbad8d 100644 --- a/desktop/src/app/mount/mount.component.ts +++ b/desktop/src/app/mount/mount.component.ts @@ -279,14 +279,13 @@ export class MountComponent implements AfterContentInit, OnDestroy, Pingable { hotkeys('e', { keyup: true }, event => { event.preventDefault(); this.moveTo('NE', event.type === 'keydown') }) hotkeys('z', { keyup: true }, event => { event.preventDefault(); this.moveTo('SW', event.type === 'keydown') }) hotkeys('c', { keyup: true }, event => { event.preventDefault(); this.moveTo('SE', event.type === 'keydown') }) - - this.pinger.register(this, 30000) } async ngAfterContentInit() { - this.route.queryParams.subscribe(e => { + this.route.queryParams.subscribe(async e => { const mount = JSON.parse(decodeURIComponent(e.data)) as Mount - this.mountChanged(mount) + await this.mountChanged(mount) + this.pinger.register(this, 30000) }) } diff --git a/desktop/src/app/rotator/rotator.component.ts b/desktop/src/app/rotator/rotator.component.ts index d9a8215dc..6886e7044 100644 --- a/desktop/src/app/rotator/rotator.component.ts +++ b/desktop/src/app/rotator/rotator.component.ts @@ -47,14 +47,13 @@ export class RotatorComponent implements AfterViewInit, OnDestroy, Pingable { }) } }) - - pinger.register(this, 30000) } async ngAfterViewInit() { - this.route.queryParams.subscribe(e => { + this.route.queryParams.subscribe(async e => { const rotator = JSON.parse(decodeURIComponent(e.data)) as Rotator - this.rotatorChanged(rotator) + await this.rotatorChanged(rotator) + this.pinger.register(this, 30000) }) } diff --git a/desktop/src/app/sequencer/sequencer.component.html b/desktop/src/app/sequencer/sequencer.component.html index 52c823755..998f0830e 100644 --- a/desktop/src/app/sequencer/sequencer.component.html +++ b/desktop/src/app/sequencer/sequencer.component.html @@ -27,13 +27,8 @@ - - - - - - +
@@ -144,6 +139,8 @@ (deviceChange)="wheelChanged()" /> +
@@ -226,9 +223,9 @@
+ severity="success" size="small" [text]="true" /> + severity="danger" size="small" [text]="true" />
diff --git a/desktop/src/app/sequencer/sequencer.component.ts b/desktop/src/app/sequencer/sequencer.component.ts index bde94f520..43b06746c 100644 --- a/desktop/src/app/sequencer/sequencer.component.ts +++ b/desktop/src/app/sequencer/sequencer.component.ts @@ -13,6 +13,7 @@ import { JsonFile } from '../../shared/types/app.types' import { Camera, CameraCaptureEvent, CameraStartCapture } from '../../shared/types/camera.types' import { Focuser } from '../../shared/types/focuser.types' import { Mount } from '../../shared/types/mount.types' +import { Rotator } from '../../shared/types/rotator.types' import { EMPTY_SEQUENCE_PLAN, SEQUENCE_ENTRY_PROPERTIES, SequenceCaptureMode, SequenceEntryProperty, SequencePlan, SequencerEvent } from '../../shared/types/sequencer.types' import { FilterWheel } from '../../shared/types/wheel.types' import { deviceComparator } from '../../shared/utils/comparators' @@ -34,11 +35,13 @@ export class SequencerComponent implements AfterContentInit, OnDestroy, Pingable mounts: Mount[] = [] wheels: FilterWheel[] = [] focusers: Focuser[] = [] + rotators: Rotator[] = [] camera?: Camera mount?: Mount wheel?: FilterWheel focuser?: Focuser + rotator?: Rotator readonly captureModes: SequenceCaptureMode[] = ['FULLY', 'INTERLEAVED'] readonly plan = structuredClone(EMPTY_SEQUENCE_PLAN) @@ -177,43 +180,53 @@ export class SequencerComponent implements AfterContentInit, OnDestroy, Pingable }) electron.on('CAMERA.UPDATED', event => { - ngZone.run(() => { - const camera = this.cameras.find(e => e.id === event.device.id) + const camera = this.cameras.find(e => e.id === event.device.id) - if (camera) { + if (camera) { + ngZone.run(() => { Object.assign(camera, event.device) - } - }) + }) + } }) electron.on('MOUNT.UPDATED', event => { - ngZone.run(() => { - const mount = this.mounts.find(e => e.id === event.device.id) + const mount = this.mounts.find(e => e.id === event.device.id) - if (mount) { + if (mount) { + ngZone.run(() => { Object.assign(mount, event.device) - } - }) + }) + } }) electron.on('WHEEL.UPDATED', event => { - ngZone.run(() => { - const wheel = this.wheels.find(e => e.id === event.device.id) + const wheel = this.wheels.find(e => e.id === event.device.id) - if (wheel) { + if (wheel) { + ngZone.run(() => { Object.assign(wheel, event.device) - } - }) + }) + } }) electron.on('FOCUSER.UPDATED', event => { - ngZone.run(() => { - const focuser = this.focusers.find(e => e.id === event.device.id) + const focuser = this.focusers.find(e => e.id === event.device.id) - if (focuser) { + if (focuser) { + ngZone.run(() => { Object.assign(focuser, event.device) - } - }) + }) + } + }) + + electron.on('ROTATOR.UPDATED', event => { + const rotator = this.rotators.find(e => e.id === event.device.id) + + if (rotator) { + ngZone.run(() => { + Object.assign(rotator, event.device) + }) + } }) electron.on('SEQUENCER.ELAPSED', event => { @@ -237,15 +250,16 @@ export class SequencerComponent implements AfterContentInit, OnDestroy, Pingable for (const p of SEQUENCE_ENTRY_PROPERTIES) { this.availableEntryPropertiesToApply.set(p, true) } - - pinger.register(this, 30000) } async ngAfterContentInit() { + this.pinger.register(this, 30000) + this.cameras = (await this.api.cameras()).sort(deviceComparator) this.mounts = (await this.api.mounts()).sort(deviceComparator) this.wheels = (await this.api.wheels()).sort(deviceComparator) this.focusers = (await this.api.focusers()).sort(deviceComparator) + this.rotators = (await this.api.rotators()).sort(deviceComparator) this.loadSavedJsonFileFromPathOrAddDefault() @@ -262,6 +276,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy, Pingable if (this.mount) this.api.mountListen(this.mount) if (this.focuser) this.api.focuserListen(this.focuser) if (this.wheel) this.api.wheelListen(this.wheel) + if (this.rotator) this.api.rotatorListen(this.rotator) } private enableOrDisableTopbarMenu(enable: boolean) { @@ -272,6 +287,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy, Pingable const camera = this.camera ?? this.cameras[0] // const wheel = this.wheel ?? this.wheels[0] // const focuser = this.focuser ?? this.focusers[0] + // const rotator = this.rotator ?? this.rotators[0] this.plan.entries.push({ enabled: true, @@ -290,6 +306,20 @@ export class SequencerComponent implements AfterContentInit, OnDestroy, Pingable frameFormat: camera?.frameFormats[0], autoSave: true, autoSubFolderMode: 'OFF', + dither: { + enabled: false, + amount: 0, + raOnly: false, + afterExposures: 0 + }, + liveStacking: { + enabled: false, + type: 'SIRIL', + executablePath: '', + rotate: 0, + use32Bits: false, + slot: 1, + }, }) this.savePlan() @@ -342,8 +372,9 @@ export class SequencerComponent implements AfterContentInit, OnDestroy, Pingable this.camera = this.cameras.find(e => e.name === this.plan.camera?.name) ?? this.cameras[0] this.mount = this.mounts.find(e => e.name === this.plan.mount?.name) ?? this.mounts[0] - this.focuser = this.focusers.find(e => e.name === this.plan.focuser?.name) ?? this.focusers[0] this.wheel = this.wheels.find(e => e.name === this.plan.wheel?.name) ?? this.wheels[0] + this.focuser = this.focusers.find(e => e.name === this.plan.focuser?.name) ?? this.focusers[0] + this.rotator = this.rotators.find(e => e.name === this.plan.rotator?.name) ?? this.rotators[0] return plan.entries.length } @@ -363,16 +394,6 @@ export class SequencerComponent implements AfterContentInit, OnDestroy, Pingable } } - async chooseSavePath() { - const defaultPath = this.plan.savePath - const path = await this.electron.openDirectory({ defaultPath }) - - if (path) { - this.plan.savePath = path - this.savePlan() - } - } - async showCameraDialog(entry: CameraStartCapture) { if (await CameraComponent.showAsDialog(this.browserWindow, 'SEQUENCER', this.camera!, entry)) { this.savePlan() @@ -401,11 +422,16 @@ export class SequencerComponent implements AfterContentInit, OnDestroy, Pingable this.ping() } + rotatorChanged() { + this.ping() + } + savePlan() { this.plan.camera = this.camera this.plan.mount = this.mount this.plan.wheel = this.wheel this.plan.focuser = this.focuser + this.plan.rotator = this.rotator this.storage.set(SEQUENCER_PLAN_KEY, this.plan) this.savedPathWasModified = !!this.savedPath } diff --git a/desktop/src/app/settings/settings.component.html b/desktop/src/app/settings/settings.component.html index e2cdc4107..2e45ca29f 100644 --- a/desktop/src/app/settings/settings.component.html +++ b/desktop/src/app/settings/settings.component.html @@ -1,90 +1,153 @@ -
- - -
- +
+
+ + + + +
+
+
+
+
- - - + + +
- - -
- - - +
+
+
+
+
+ + + - +
+
+ - - +
+ + - - - - - - - - - +
+
+ +
+
+ + - - +
+
+ +
- - -
- - +
+
+
+
+ + - + - +
+
+ +
+
+ - +
+
+ + + + +
+
+ - - - - - - +
+
+ + + + +
+
+
+
+
+
+ + + + +
+
+ +
+
+ + +
- - +
+
\ No newline at end of file diff --git a/desktop/src/app/settings/settings.component.ts b/desktop/src/app/settings/settings.component.ts index 2d8931004..5802796bb 100644 --- a/desktop/src/app/settings/settings.component.ts +++ b/desktop/src/app/settings/settings.component.ts @@ -1,12 +1,12 @@ import { AfterViewInit, Component, HostListener, OnDestroy } from '@angular/core' -import path from 'path' import { LocationDialog } from '../../shared/dialogs/location/location.dialog' -import { ApiService } from '../../shared/services/api.service' +import { DropdownOptionsPipe } from '../../shared/pipes/dropdown-options' import { ElectronService } from '../../shared/services/electron.service' import { PreferenceService } from '../../shared/services/preference.service' import { PrimeService } from '../../shared/services/prime.service' import { EMPTY_LOCATION, Location } from '../../shared/types/atlas.types' -import { PlateSolverOptions, PlateSolverType, StarDetectionOptions, StarDetectorType } from '../../shared/types/settings.types' +import { LiveStackerType, LiveStackingRequest } from '../../shared/types/camera.types' +import { PlateSolverRequest, PlateSolverType, StarDetectionRequest, StarDetectorType } from '../../shared/types/settings.types' import { AppComponent } from '../app.component' @Component({ @@ -16,31 +16,59 @@ import { AppComponent } from '../app.component' }) export class SettingsComponent implements AfterViewInit, OnDestroy { + tab = 0 + readonly tabs: { id: number, name: string }[] = [ + { + id: 0, + name: 'Location' + }, + { + id: 1, + name: 'Plate Solver' + }, + { + id: 2, + name: 'Star Detection' + }, + { + id: 3, + name: 'Live Stacking' + }, + ] + readonly locations: Location[] location: Location - solverType: PlateSolverType = 'ASTAP' - readonly solvers = new Map() + plateSolverType: PlateSolverType = 'ASTAP' + readonly plateSolvers = new Map() starDetectorType: StarDetectorType = 'ASTAP' - readonly starDetectors = new Map() + readonly starDetectors = new Map() + + liveStackerType: LiveStackerType = 'SIRIL' + readonly liveStackers = new Map() constructor( app: AppComponent, - private api: ApiService, private preference: PreferenceService, private electron: ElectronService, private prime: PrimeService, + private dropdownOptions: DropdownOptionsPipe, ) { app.title = 'Settings' this.locations = preference.locations.get() this.location = preference.selectedLocation.get(this.locations[0]) - this.solvers.set('ASTAP', preference.plateSolverOptions('ASTAP').get()) - this.solvers.set('ASTROMETRY_NET_ONLINE', preference.plateSolverOptions('ASTROMETRY_NET_ONLINE').get()) - - this.starDetectors.set('ASTAP', preference.starDetectionOptions('ASTAP').get()) + for (const type of dropdownOptions.transform('PLATE_SOLVER')) { + this.plateSolvers.set(type, preference.plateSolverRequest(type).get()) + } + for (const type of dropdownOptions.transform('STAR_DETECTOR')) { + this.starDetectors.set(type, preference.starDetectionRequest(type).get()) + } + for (const type of dropdownOptions.transform('LIVE_STACKER')) { + this.liveStackers.set(type, preference.liveStackingRequest(type).get()) + } } async ngAfterViewInit() { } @@ -102,31 +130,15 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { this.electron.send('LOCATION.CHANGED', this.location) } - async chooseExecutablePathForPlateSolver() { - const options = this.solvers.get(this.solverType)! - this.chooseExecutablePath(options) - } - - async chooseExecutablePathForStarDetection() { - const options = this.solvers.get(this.starDetectorType)! - this.chooseExecutablePath(options) - } - - private async chooseExecutablePath(options: { executablePath: string }) { - const executablePath = await this.electron.openFile({ defaultPath: path.dirname(options.executablePath) }) - - if (executablePath) { - options.executablePath = executablePath - this.save() - } - - return executablePath - } - save() { - this.preference.plateSolverOptions('ASTAP').set(this.solvers.get('ASTAP')!) - this.preference.plateSolverOptions('ASTROMETRY_NET_ONLINE').set(this.solvers.get('ASTROMETRY_NET_ONLINE')!) - - this.preference.starDetectionOptions('ASTAP').set(this.starDetectors.get('ASTAP')!) + for (const type of this.dropdownOptions.transform('PLATE_SOLVER')) { + this.preference.plateSolverRequest(type).set(this.plateSolvers.get(type)) + } + for (const type of this.dropdownOptions.transform('STAR_DETECTOR')) { + this.preference.starDetectionRequest(type).set(this.starDetectors.get(type)) + } + for (const type of this.dropdownOptions.transform('LIVE_STACKER')) { + this.preference.liveStackingRequest(type).set(this.liveStackers.get(type)) + } } } \ No newline at end of file diff --git a/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts b/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts index c25094060..b48c1ac3d 100644 --- a/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts +++ b/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts @@ -47,6 +47,8 @@ export class CameraExposureComponent { this.state = 'EXPOSURING' } else if (event.state === 'IDLE' || event.state === 'CAPTURE_FINISHED') { this.reset() + } else if (event.state !== 'EXPOSURE_FINISHED') { + this.state = event.state } return this.state !== undefined diff --git a/desktop/src/shared/components/map/map.component.html b/desktop/src/shared/components/map/map.component.html index e4e6a7ec1..0c20a3260 100644 --- a/desktop/src/shared/components/map/map.component.html +++ b/desktop/src/shared/components/map/map.component.html @@ -1 +1 @@ -
+
diff --git a/desktop/src/shared/components/path-chooser/path-chooser.component.html b/desktop/src/shared/components/path-chooser/path-chooser.component.html new file mode 100644 index 000000000..f650816a8 --- /dev/null +++ b/desktop/src/shared/components/path-chooser/path-chooser.component.html @@ -0,0 +1,9 @@ +
+ + + + + +
\ No newline at end of file diff --git a/desktop/src/shared/components/path-chooser/path-chooser.component.scss b/desktop/src/shared/components/path-chooser/path-chooser.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/desktop/src/shared/components/path-chooser/path-chooser.component.ts b/desktop/src/shared/components/path-chooser/path-chooser.component.ts new file mode 100644 index 000000000..086c5453e --- /dev/null +++ b/desktop/src/shared/components/path-chooser/path-chooser.component.ts @@ -0,0 +1,59 @@ +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core' +import { dirname } from 'path' +import { ElectronService } from '../../services/electron.service' + +@Component({ + selector: 'neb-path-chooser', + templateUrl: './path-chooser.component.html', + styleUrls: ['./path-chooser.component.scss'], +}) +export class PathChooserComponent implements OnChanges { + + @Input({ required: true }) + readonly key!: string + + @Input() + readonly label?: string + + @Input() + readonly placeholder?: string + + @Input() + readonly disabled: boolean = false + + @Input() + readonly readonly: boolean = false + + @Input({ required: true }) + readonly directory!: boolean + + @Input() + path?: string + + @Output() + readonly pathChange = new EventEmitter() + + constructor(private electron: ElectronService) { } + + ngOnChanges(changes: SimpleChanges) { + if (changes.path) { + this.path = changes.path.currentValue + } + } + + async choosePath() { + const storageKey = `pathChooser.${this.key}.defaultPath` + const defaultPath = localStorage.getItem(storageKey) + const dirName = defaultPath && !this.directory ? dirname(defaultPath) : defaultPath + + const path = await (this.directory + ? this.electron.openDirectory({ defaultPath: dirName || this.path }) + : this.electron.openFile({ defaultPath: dirName || this.path })) + + if (path) { + this.path = path + this.pathChange.emit(path) + localStorage.setItem(storageKey, path) + } + } +} \ No newline at end of file diff --git a/desktop/src/shared/dialogs/location/location.dialog.html b/desktop/src/shared/dialogs/location/location.dialog.html index 7fe83c726..21068ab49 100644 --- a/desktop/src/shared/dialogs/location/location.dialog.html +++ b/desktop/src/shared/dialogs/location/location.dialog.html @@ -1,42 +1,42 @@ -
-
+
+
-
+
-
+
+ + + + +
+
-
+
-
- - - - -
-