From 0578d1e0f438a2e7e131cc98ba07d13ae2a04312 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 27 Jan 2024 13:53:54 -0300 Subject: [PATCH 01/87] [api]: Implement Three Point Polar Alignment --- .../kotlin/nebulosa/api/atlas/BodyPosition.kt | 1 + .../api/atlas/SimbadDatabaseReader.kt | 1 + .../api/cameras/CameraExposureStep.kt | 2 +- .../nebulosa/api/focusers/FocusOffsetStep.kt | 2 +- .../api/guiding/DitherAfterExposureStep.kt | 2 +- .../nebulosa/api/guiding/GuideStepHistory.kt | 2 +- .../nebulosa/api/image/ImageAnnotation.kt | 7 +- .../nebulosa/api/mounts/MountService.kt | 2 +- .../kotlin/nebulosa/api/wheels/WheelStep.kt | 2 +- nebulosa-alignment/build.gradle.kts | 4 + .../nebulosa/alignment/StarAlignment.kt | 3 - .../algorithms/ThreePointPolarAlignment.kt | 5 - .../alignment/polar/PolarAlignment.kt | 8 + .../point/three/PolarErrorDetermination.kt | 69 + .../alignment/polar/point/three/Position.kt | 69 + .../point/three/ThreePointPolarAlignment.kt | 87 + .../three/ThreePointPolarAlignmentResult.kt | 8 + .../polar/point/three/Topocentric.kt | 10 + .../batch/processing/AsyncJobLauncher.kt | 2 +- .../nebulosa/batch/processing/JobExecution.kt | 2 +- .../concurrency/CancellationListener.kt | 5 - .../concurrency/{ => atomic}/Incrementer.kt | 2 +- .../{ => cancel}/CancellationSource.kt | 4 +- .../{ => cancel}/CancellationToken.kt | 48 +- .../{ => latch}/CountUpDownLatch.kt | 4 +- .../common/concurrency/latch/Pauser.kt | 41 + .../kotlin/nebulosa/common/time/Stopwatch.kt | 106 ++ .../src/test/kotlin/CancellationTokenTest.kt | 4 +- .../src/test/kotlin/CountUpDownLatchTest.kt | 2 +- .../kotlin/nebulosa/constants/Distance.kt | 6 +- .../nebulosa/erfa/AstrometryParameters.kt | 7 +- .../nebulosa/erfa/CartesianCoordinate.kt | 40 +- .../src/main/kotlin/nebulosa/erfa/Erfa.kt | 1421 +++++++++++++---- .../PrecessionNutationAnglesAndMatrices.kt | 14 + .../erfa/PrecessionNutationMatrices.kt | 13 - nebulosa-erfa/src/test/kotlin/ErfaTest.kt | 345 +++- .../src/main/kotlin/nebulosa/fits/Header.kt | 2 +- .../kotlin/nebulosa/fits/ReadOnlyHeader.kt | 4 +- .../nebulosa/guiding/internal/GuidePoint.kt | 29 +- .../kotlin/nebulosa/guiding/internal/Point.kt | 31 +- .../nebulosa/guiding/phd2/PHD2Guider.kt | 4 +- .../main/kotlin/nebulosa/guiding/Guider.kt | 2 +- .../src/main/kotlin/nebulosa/math/Matrix3D.kt | 16 +- .../src/main/kotlin/nebulosa/math/Point2D.kt | 18 + .../src/main/kotlin/nebulosa/math/Point3D.kt | 17 + .../src/main/kotlin/nebulosa/math/Pressure.kt | 2 +- .../src/main/kotlin/nebulosa/math/Unsafe.kt | 59 + .../src/main/kotlin/nebulosa/math/Vector3D.kt | 104 +- nebulosa-math/src/test/kotlin/Vector3DTest.kt | 75 +- .../nova/position/PlanetograhicPosition.kt | 4 +- .../phd2/client/commands/SetLockPosition.kt | 4 +- .../nebulosa/plate/solving/PlateSolution.kt | 14 +- nebulosa-star-detection/build.gradle.kts | 1 + .../nebulosa/star/detection/ImageStar.kt | 12 +- .../main/kotlin/nebulosa/test/MathMatchers.kt | 51 + nebulosa-time/build.gradle.kts | 1 + .../main/kotlin/nebulosa/time}/CurrentTime.kt | 27 +- .../kotlin/nebulosa/wcs/PixelCoordinates.kt | 3 +- 58 files changed, 2245 insertions(+), 585 deletions(-) delete mode 100644 nebulosa-alignment/src/main/kotlin/nebulosa/alignment/StarAlignment.kt delete mode 100644 nebulosa-alignment/src/main/kotlin/nebulosa/alignment/algorithms/ThreePointPolarAlignment.kt create mode 100644 nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/PolarAlignment.kt create mode 100644 nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/PolarErrorDetermination.kt create mode 100644 nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt create mode 100644 nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt create mode 100644 nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt create mode 100644 nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Topocentric.kt delete mode 100644 nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/CancellationListener.kt rename nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/{ => atomic}/Incrementer.kt (92%) rename nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/{ => cancel}/CancellationSource.kt (74%) rename nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/{ => cancel}/CancellationToken.kt (56%) rename nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/{ => latch}/CountUpDownLatch.kt (92%) create mode 100644 nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauser.kt create mode 100644 nebulosa-common/src/main/kotlin/nebulosa/common/time/Stopwatch.kt create mode 100644 nebulosa-erfa/src/main/kotlin/nebulosa/erfa/PrecessionNutationAnglesAndMatrices.kt delete mode 100644 nebulosa-erfa/src/main/kotlin/nebulosa/erfa/PrecessionNutationMatrices.kt create mode 100644 nebulosa-math/src/main/kotlin/nebulosa/math/Point2D.kt create mode 100644 nebulosa-math/src/main/kotlin/nebulosa/math/Point3D.kt create mode 100644 nebulosa-math/src/main/kotlin/nebulosa/math/Unsafe.kt create mode 100644 nebulosa-test/src/main/kotlin/nebulosa/test/MathMatchers.kt rename {api/src/main/kotlin/nebulosa/api/atlas => nebulosa-time/src/main/kotlin/nebulosa/time}/CurrentTime.kt (76%) diff --git a/api/src/main/kotlin/nebulosa/api/atlas/BodyPosition.kt b/api/src/main/kotlin/nebulosa/api/atlas/BodyPosition.kt index 0f3ccc328..0bd8e7b5c 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/BodyPosition.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/BodyPosition.kt @@ -14,6 +14,7 @@ import nebulosa.math.deg import nebulosa.nova.astrometry.Constellation import nebulosa.nova.position.GeographicPosition import nebulosa.skycatalog.SkyObject +import nebulosa.time.CurrentTime data class BodyPosition( @field:JsonSerialize(using = RightAscensionSerializer::class) val rightAscensionJ2000: Angle, diff --git a/api/src/main/kotlin/nebulosa/api/atlas/SimbadDatabaseReader.kt b/api/src/main/kotlin/nebulosa/api/atlas/SimbadDatabaseReader.kt index 45aabb327..4df5c3b4a 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/SimbadDatabaseReader.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/SimbadDatabaseReader.kt @@ -6,6 +6,7 @@ import nebulosa.math.kms import nebulosa.math.mas import nebulosa.skycatalog.SkyObject import nebulosa.skycatalog.SkyObjectType +import nebulosa.time.CurrentTime import okio.BufferedSource import okio.Source import okio.buffer diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt index 9e1c2bacf..8d005df3b 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt @@ -9,7 +9,7 @@ import nebulosa.batch.processing.StepExecution import nebulosa.batch.processing.StepResult import nebulosa.batch.processing.delay.DelayStep import nebulosa.batch.processing.delay.DelayStepListener -import nebulosa.common.concurrency.CountUpDownLatch +import nebulosa.common.concurrency.latch.CountUpDownLatch import nebulosa.indi.device.camera.* import nebulosa.io.transferAndClose import nebulosa.log.debug diff --git a/api/src/main/kotlin/nebulosa/api/focusers/FocusOffsetStep.kt b/api/src/main/kotlin/nebulosa/api/focusers/FocusOffsetStep.kt index 563854f54..78b44d61e 100644 --- a/api/src/main/kotlin/nebulosa/api/focusers/FocusOffsetStep.kt +++ b/api/src/main/kotlin/nebulosa/api/focusers/FocusOffsetStep.kt @@ -4,7 +4,7 @@ import nebulosa.api.wheels.WheelStep import nebulosa.batch.processing.Step import nebulosa.batch.processing.StepExecution import nebulosa.batch.processing.StepResult -import nebulosa.common.concurrency.CountUpDownLatch +import nebulosa.common.concurrency.latch.CountUpDownLatch import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.focuser.FocuserEvent import nebulosa.indi.device.focuser.FocuserMoveFailed diff --git a/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureStep.kt b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureStep.kt index 073e66c72..62e7972bb 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureStep.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureStep.kt @@ -3,7 +3,7 @@ package nebulosa.api.guiding import nebulosa.batch.processing.Step import nebulosa.batch.processing.StepExecution import nebulosa.batch.processing.StepResult -import nebulosa.common.concurrency.CountUpDownLatch +import nebulosa.common.concurrency.latch.CountUpDownLatch import nebulosa.guiding.GuideState import nebulosa.guiding.Guider import nebulosa.guiding.GuiderListener diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuideStepHistory.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuideStepHistory.kt index 720306b58..e85b1732f 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/GuideStepHistory.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuideStepHistory.kt @@ -1,6 +1,6 @@ package nebulosa.api.guiding -import nebulosa.common.concurrency.Incrementer +import nebulosa.common.concurrency.atomic.Incrementer import nebulosa.guiding.GuideStep import java.util.* diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageAnnotation.kt b/api/src/main/kotlin/nebulosa/api/image/ImageAnnotation.kt index 7c8d7d124..145810a0f 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageAnnotation.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageAnnotation.kt @@ -1,16 +1,17 @@ package nebulosa.api.image import nebulosa.math.Angle +import nebulosa.math.Point2D import nebulosa.skycatalog.DeepSkyObject import nebulosa.skycatalog.SkyObject data class ImageAnnotation( - val x: Double, - val y: Double, + override val x: Double, + override val y: Double, val star: DeepSkyObject? = null, val dso: DeepSkyObject? = null, val minorPlanet: SkyObject? = null, -) { +) : Point2D { internal data class MinorPlanet( override val id: Long = 0L, diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt index 81cc6f13c..8f0a60570 100644 --- a/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt @@ -1,6 +1,5 @@ package nebulosa.api.mounts -import nebulosa.api.atlas.CurrentTime import nebulosa.api.beans.annotations.Subscriber import nebulosa.api.image.ImageBucket import nebulosa.constants.PI @@ -16,6 +15,7 @@ import nebulosa.nova.frame.Ecliptic import nebulosa.nova.position.GeographicPosition import nebulosa.nova.position.Geoid import nebulosa.nova.position.ICRF +import nebulosa.time.CurrentTime import nebulosa.wcs.WCS import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode diff --git a/api/src/main/kotlin/nebulosa/api/wheels/WheelStep.kt b/api/src/main/kotlin/nebulosa/api/wheels/WheelStep.kt index 59aed5181..caeefa4aa 100644 --- a/api/src/main/kotlin/nebulosa/api/wheels/WheelStep.kt +++ b/api/src/main/kotlin/nebulosa/api/wheels/WheelStep.kt @@ -3,7 +3,7 @@ package nebulosa.api.wheels import nebulosa.batch.processing.Step import nebulosa.batch.processing.StepExecution import nebulosa.batch.processing.StepResult -import nebulosa.common.concurrency.CountUpDownLatch +import nebulosa.common.concurrency.latch.CountUpDownLatch import nebulosa.indi.device.filterwheel.FilterWheel import nebulosa.indi.device.filterwheel.FilterWheelEvent import nebulosa.indi.device.filterwheel.FilterWheelMoveFailed diff --git a/nebulosa-alignment/build.gradle.kts b/nebulosa-alignment/build.gradle.kts index cdcb818a3..8f0d8dcab 100644 --- a/nebulosa-alignment/build.gradle.kts +++ b/nebulosa-alignment/build.gradle.kts @@ -4,7 +4,11 @@ plugins { } dependencies { + api(project(":nebulosa-erfa")) + api(project(":nebulosa-time")) + api(project(":nebulosa-indi-device")) api(project(":nebulosa-plate-solving")) + api(project(":nebulosa-star-detection")) implementation(project(":nebulosa-log")) testImplementation(project(":nebulosa-test")) } diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/StarAlignment.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/StarAlignment.kt deleted file mode 100644 index 095664c82..000000000 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/StarAlignment.kt +++ /dev/null @@ -1,3 +0,0 @@ -package nebulosa.alignment - -interface StarAlignment diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/algorithms/ThreePointPolarAlignment.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/algorithms/ThreePointPolarAlignment.kt deleted file mode 100644 index 48f433b55..000000000 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/algorithms/ThreePointPolarAlignment.kt +++ /dev/null @@ -1,5 +0,0 @@ -package nebulosa.alignment.algorithms - -import nebulosa.alignment.StarAlignment - -class ThreePointPolarAlignment : StarAlignment diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/PolarAlignment.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/PolarAlignment.kt new file mode 100644 index 000000000..57210a97a --- /dev/null +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/PolarAlignment.kt @@ -0,0 +1,8 @@ +package nebulosa.alignment.polar + +import nebulosa.imaging.Image + +interface PolarAlignment { + + fun align(image: Image): T +} diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/PolarErrorDetermination.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/PolarErrorDetermination.kt new file mode 100644 index 000000000..509495166 --- /dev/null +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/PolarErrorDetermination.kt @@ -0,0 +1,69 @@ +package nebulosa.alignment.polar.point.three + +import nebulosa.math.Angle +import nebulosa.math.Vector3D +import nebulosa.plate.solving.PlateSolution + +internal data class PolarErrorDetermination( + @JvmField val initialReferenceFrame: PlateSolution, + @JvmField val firstPosition: Vector3D, + @JvmField val secondPosition: Vector3D, + @JvmField val thirdPosition: Vector3D, + @JvmField val longitude: Angle, @JvmField val latitude: Angle, +) { + + inline val isNorthern + get() = latitude > 0.0 + + @JvmField val plane = with(Vector3D.plane(firstPosition, secondPosition, thirdPosition)) { + if (isNorthern && x < 0 || !isNorthern && x > 0) { + // Flip vector if pointing to the wrong direction. + -this + } else { + this + } + } + + @JvmField val initialMountAxisErrorPosition = Position(plane, longitude, latitude) + +// init { +// calculateMountAxisError() +// } +// +// val isInitialErrorLarge +// get() = initialMountAxisTotalError.Degree > 2 && initialMountAxisTotalError.Degree <= 10 +// +// val isInitialErrorHuge +// get() = initialMountAxisTotalError.Degree > 10 +// +// private fun calculateMountAxisError() { +// val altitudeError: Double +// var azimuthError: Double +// +// val pole = abs(latitude) +// +// if (isNorthern) { +// altitudeError = initialMountAxisErrorPosition.topocentric.altitude - pole +// azimuthError = initialMountAxisErrorPosition.topocentric.azimuth +// } else { +// altitudeError = pole - initialMountAxisErrorPosition.topocentric.altitude +// azimuthError = initialMountAxisErrorPosition.topocentric.azimuth + PI +// } +// +// if (azimuthError > PI) { +// azimuthError -= TAU +// } +// if (azimuthError < -PI) { +// azimuthError += TAU +// } +// +// initialMountAxisAltitudeError = altitudeError +// initialMountAxisAzimuthError = azimuthError +// initialMountAxisTotalError = hypot(initialMountAxisAltitudeError, initialMountAxisAzimuthError) +// +// currentMountAxisAltitudeError = altitudeError +// currentMountAxisAzimuthError = azimuthError +// currentMountAxisTotalError = hypot(initialMountAxisAltitudeError, initialMountAxisAzimuthError) +// currentReferenceFrame = initialReferenceFrame +// } +} diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt new file mode 100644 index 000000000..f8f40ab06 --- /dev/null +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt @@ -0,0 +1,69 @@ +package nebulosa.alignment.polar.point.three + +import nebulosa.constants.PIOVERTWO +import nebulosa.erfa.CartesianCoordinate +import nebulosa.erfa.eraAtco13 +import nebulosa.erfa.eraAtic13 +import nebulosa.erfa.eraEe06a +import nebulosa.math.Angle +import nebulosa.math.Vector3D +import nebulosa.math.normalized +import nebulosa.time.IERS +import nebulosa.time.UTC +import kotlin.math.acos + +internal class Position { + + @JvmField val topocentric: Topocentric + @JvmField val vector: Vector3D + + constructor( + rightAscension: Angle, declination: Angle, + longitude: Angle, latitude: Angle, + ) { + + val time = UTC.now() + val ee = eraEe06a(time.tt.whole, time.tt.fraction) + val (ri, di) = eraAtic13((rightAscension + ee).normalized, declination, time.tdb.whole, time.tdb.fraction) + val dut1 = IERS.delta(time) + val (xp, yp) = IERS.pmAngles(time) + val (b) = eraAtco13(ri, di, 0.0, 0.0, 0.0, 0.0, time.whole, time.fraction, dut1, longitude, latitude, 0.0, xp, yp, 1013.25, 15.0, 0.0, 0.55) + val az = b[0] // aob + val alt = PIOVERTWO - b[1] // zob + topocentric = Topocentric(az, alt, longitude, latitude, time) + vector = CartesianCoordinate.of(-topocentric.azimuth, PIOVERTWO - topocentric.altitude, 1.0) + } + + constructor(topocentric: Topocentric, vector: Vector3D) { + this.topocentric = topocentric + this.vector = vector + } + + constructor(vector: Vector3D, longitude: Angle, latitude: Angle) { + this.topocentric = if (vector[0] == 0.0 && vector[1] == 0.0) { + Topocentric(0.0, PIOVERTWO, longitude, latitude, UTC.now()) + } else { + Topocentric(-vector.longitude, PIOVERTWO - acos(vector[2]), longitude, latitude, UTC.now()) + } + + this.vector = vector + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Position) return false + + if (topocentric != other.topocentric) return false + if (vector != other.vector) return false + + return true + } + + override fun hashCode(): Int { + var result = topocentric.hashCode() + result = 31 * result + vector.hashCode() + return result + } + + override fun toString() = "Position(topocentric=$topocentric, vector=$vector)" +} diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt new file mode 100644 index 000000000..c40dd4134 --- /dev/null +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt @@ -0,0 +1,87 @@ +package nebulosa.alignment.polar.point.three + +import nebulosa.alignment.polar.PolarAlignment +import nebulosa.alignment.polar.point.three.ThreePointPolarAlignment.State.* +import nebulosa.constants.DEG2RAD +import nebulosa.imaging.Image +import nebulosa.indi.device.mount.Mount +import nebulosa.math.Point2D +import nebulosa.plate.solving.PlateSolution +import nebulosa.plate.solving.PlateSolver +import nebulosa.star.detection.StarDetector + +data class ThreePointPolarAlignment( + private val solver: PlateSolver, + private val starDetector: StarDetector, + private val mount: Mount?, // null for manual mode. +) : PolarAlignment { + + private enum class State { + FIRST_MEASURE, + SECOND_MEASURE, + THIRD_MEASURE, + ADJUSTMENT_MEASURE, + } + + @Volatile private var state = FIRST_MEASURE + private val solutions = HashMap(4) + + override fun align(image: Image): ThreePointPolarAlignmentResult { + return when (state) { + FIRST_MEASURE -> measure(image, SECOND_MEASURE) + SECOND_MEASURE -> measure(image, THIRD_MEASURE) + THIRD_MEASURE -> measure(image, ADJUSTMENT_MEASURE) + ADJUSTMENT_MEASURE -> measure(image, ADJUSTMENT_MEASURE) + } + } + + private fun measure(image: Image, nextState: State): ThreePointPolarAlignmentResult { + val radius = if (mount == null) 0.0 else DEFAULT_RADIUS + val solution = solver.solve(null, image, mount?.rightAscension ?: 0.0, mount?.declination ?: 0.0, radius) + + return if (!solution.solved) { + ThreePointPolarAlignmentResult.NoPlateSolution + } else { + solutions[state] = solution + + if (state != ADJUSTMENT_MEASURE) { + state = nextState + ThreePointPolarAlignmentResult.NeedMoreMeasure + } else { + computeAdjustment() + } + } + } + + private fun computeAdjustment(): ThreePointPolarAlignmentResult { + return ThreePointPolarAlignmentResult.NeedMoreMeasure + } + + fun selectNewReferenceStar(image: Image, point: Point2D) { + val referenceStar = starDetector.closestStarPosition(image, point) + } + + companion object { + + private const val DEFAULT_RADIUS = 4 * DEG2RAD + + @JvmStatic + internal fun StarDetector.closestStarPosition(image: Image, reference: Point2D): Point2D { + val detectedStars = detect(image) + + var closestPoint = reference + var minDistance = Double.MAX_VALUE + + for (star in detectedStars) { + val distance = star.distance(reference) + + if (distance < minDistance) { + closestPoint = star + minDistance = distance + } + } + + return closestPoint + } + } +} diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt new file mode 100644 index 000000000..7dd272e64 --- /dev/null +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt @@ -0,0 +1,8 @@ +package nebulosa.alignment.polar.point.three + +sealed interface ThreePointPolarAlignmentResult { + + data object NeedMoreMeasure : ThreePointPolarAlignmentResult + + data object NoPlateSolution : ThreePointPolarAlignmentResult +} diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Topocentric.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Topocentric.kt new file mode 100644 index 000000000..b996328e7 --- /dev/null +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Topocentric.kt @@ -0,0 +1,10 @@ +package nebulosa.alignment.polar.point.three + +import nebulosa.math.Angle +import nebulosa.time.UTC + +data class Topocentric( + @JvmField val azimuth: Angle, @JvmField val altitude: Angle, + @JvmField val longitude: Angle, @JvmField val latitude: Angle, + @JvmField val time: UTC, +) diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt index dbc9d05e2..ba1caa1ce 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt @@ -1,7 +1,7 @@ package nebulosa.batch.processing import nebulosa.common.concurrency.CancellationListener -import nebulosa.common.concurrency.CancellationSource +import nebulosa.common.concurrency.cancel.CancellationSource import nebulosa.log.debug import nebulosa.log.loggerFor import java.io.Closeable diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt index b703352be..40b067138 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt @@ -1,6 +1,6 @@ package nebulosa.batch.processing -import nebulosa.common.concurrency.CancellationToken +import nebulosa.common.concurrency.cancel.CancellationToken import java.time.LocalDateTime import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutionException diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/CancellationListener.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/CancellationListener.kt deleted file mode 100644 index c75194c88..000000000 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/CancellationListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package nebulosa.common.concurrency - -import java.util.function.Consumer - -fun interface CancellationListener : Consumer diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/Incrementer.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/atomic/Incrementer.kt similarity index 92% rename from nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/Incrementer.kt rename to nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/atomic/Incrementer.kt index 2ea22efb9..46d9ede72 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/Incrementer.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/atomic/Incrementer.kt @@ -1,4 +1,4 @@ -package nebulosa.common.concurrency +package nebulosa.common.concurrency.atomic import java.util.concurrent.atomic.AtomicLong diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/CancellationSource.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationSource.kt similarity index 74% rename from nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/CancellationSource.kt rename to nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationSource.kt index 4f7adc76e..5dec99c93 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/CancellationSource.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationSource.kt @@ -1,6 +1,6 @@ -package nebulosa.common.concurrency +package nebulosa.common.concurrency.cancel -sealed interface CancellationSource { +interface CancellationSource { data object None : CancellationSource diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/CancellationToken.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt similarity index 56% rename from nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/CancellationToken.kt rename to nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt index b8ba730ee..31425330e 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/CancellationToken.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt @@ -1,48 +1,62 @@ -package nebulosa.common.concurrency +package nebulosa.common.concurrency.cancel import java.io.Closeable import java.util.concurrent.CompletableFuture import java.util.concurrent.Future import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.function.Consumer + +typealias CancellationListener = Consumer class CancellationToken private constructor(private val completable: CompletableFuture?) : Closeable, Future { constructor() : this(CompletableFuture()) private val listeners = LinkedHashSet() + private val completed = AtomicBoolean() init { completable?.whenComplete { source, _ -> - if (source != null) { - listeners.forEach { it.accept(source) } - } + synchronized(this) { + completed.set(true) + + if (source != null) { + listeners.forEach { it.accept(source) } + } - listeners.clear() + listeners.clear() + } } } - fun listen(listener: CancellationListener): Boolean { - return if (completable == null) { - false - } else if (isDone) { - listener.accept(CancellationSource.Listen) - false - } else { - listeners.add(listener) + @Synchronized + fun listen(listener: CancellationListener) { + if (completable != null) { + if (completed.get() || isDone) { + listener.accept(CancellationSource.Listen) + } else { + listeners.add(listener) + } } } - fun unlisten(listener: CancellationListener): Boolean { - return listeners.remove(listener) + @Synchronized + fun unlisten(listener: CancellationListener) { + listeners.remove(listener) } fun cancel() { cancel(true) } - @Synchronized override fun cancel(mayInterruptIfRunning: Boolean): Boolean { - completable?.complete(CancellationSource.Cancel(mayInterruptIfRunning)) ?: return false + return cancel(CancellationSource.Cancel(mayInterruptIfRunning)) + } + + @Synchronized + fun cancel(source: CancellationSource): Boolean { + completable?.complete(source) ?: return false return true } diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/CountUpDownLatch.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/CountUpDownLatch.kt similarity index 92% rename from nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/CountUpDownLatch.kt rename to nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/CountUpDownLatch.kt index 1c3ea045a..6fcc292b4 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/CountUpDownLatch.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/CountUpDownLatch.kt @@ -1,5 +1,7 @@ -package nebulosa.common.concurrency +package nebulosa.common.concurrency.latch +import nebulosa.common.concurrency.cancel.CancellationListener +import nebulosa.common.concurrency.cancel.CancellationSource import java.time.Duration import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauser.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauser.kt new file mode 100644 index 000000000..3bc30e859 --- /dev/null +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauser.kt @@ -0,0 +1,41 @@ +package nebulosa.common.concurrency.latch + +import java.io.Closeable +import java.time.Duration +import java.util.concurrent.TimeUnit + +class Pauser : Closeable { + + private val latch = CountUpDownLatch() + + val isPaused + get() = !latch.get() + + fun pause() { + if (latch.get()) { + latch.countUp(1) + } + } + + fun unpause() { + if (!latch.get()) { + latch.reset() + } + } + + override fun close() { + unpause() + } + + fun waitWhileIsPaused() { + latch.await() + } + + fun waitWhileIsPaused(timeout: Long, unit: TimeUnit): Boolean { + return latch.await(timeout, unit) + } + + fun waitWhileIsPaused(timeout: Duration): Boolean { + return latch.await(timeout) + } +} diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/time/Stopwatch.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/time/Stopwatch.kt new file mode 100644 index 000000000..4c0ac9035 --- /dev/null +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/time/Stopwatch.kt @@ -0,0 +1,106 @@ +package nebulosa.common.time + +import java.time.Duration + +/** + * A stopwatch which measures time while it's running. + * + * A stopwatch is either running or stopped. + * It measures the elapsed time that passes while the stopwatch is running. + * + * When a stopwatch is initially created, it is stopped and has measured no elapsed time. + * + * The elapsed time can be accessed in various formats using [elapsed], + * [elapsedMilliseconds], [elapsedMicroseconds] or [elapsedTicks]. + * + * The stopwatch is started by calling [start]. + */ +class Stopwatch { + + @Volatile private var start = 0L + @Volatile private var stop = 0L + + /** + * The elapsed number of clock ticks since calling [start] while the [Stopwatch] is running. + */ + val elapsedTicks + get() = if (isStopped) stop - start else System.nanoTime() - start + + /** + * The [elapsedTicks] counter converted to [Duration]. + */ + inline val elapsed: Duration + get() = Duration.ofNanos(elapsedTicks) + + /** + * The [elapsedTicks] counter converted to microseconds. + */ + inline val elapsedMicroseconds + get() = elapsedTicks / 1000L + + /** + * The [elapsedTicks] counter converted to milliseconds. + */ + inline val elapsedMilliseconds + get() = elapsedMicroseconds / 1000L + + /** + * The [elapsedTicks] counter converted to seconds. + */ + inline val elapsedSeconds + get() = elapsedMilliseconds / 1000L + + /** + * Determines whether the [Stopwatch] is currently stopped. + */ + val isStopped + get() = stop >= 0L + + /** + * Determines whether the [Stopwatch] is currently running. + */ + val isRunning + get() = stop == -1L + + /** + * Starts the [Stopwatch]. + * + * The [elapsed] count increases monotonically. If the [Stopwatch] has + * been stopped, then calling start again restarts it without resetting the + * [elapsed] count. + * + * If the [Stopwatch] is currently running, then calling start does nothing. + */ + @Synchronized + fun start() { + // Don't count the time while the stopwatch has been stopped. + if (isStopped) { + start += System.nanoTime() - stop + stop = -1L + } + } + + /** + * Stops the [Stopwatch]. + * + * The [elapsedTicks] count stops increasing after this call. If the + * [Stopwatch] is currently not running, then calling this method has no + * effect. + */ + @Synchronized + fun stop() { + if (isRunning) { + stop = System.nanoTime() + } + } + + /** + * Resets the [elapsed] count to zero. + * + * This method does not stop or start the [Stopwatch]. + */ + @Synchronized + fun reset() { + start = if (isStopped) stop else System.nanoTime() + } +} diff --git a/nebulosa-common/src/test/kotlin/CancellationTokenTest.kt b/nebulosa-common/src/test/kotlin/CancellationTokenTest.kt index 428f5d8de..16f6425a0 100644 --- a/nebulosa-common/src/test/kotlin/CancellationTokenTest.kt +++ b/nebulosa-common/src/test/kotlin/CancellationTokenTest.kt @@ -2,8 +2,8 @@ import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe -import nebulosa.common.concurrency.CancellationSource -import nebulosa.common.concurrency.CancellationToken +import nebulosa.common.concurrency.cancel.CancellationSource +import nebulosa.common.concurrency.cancel.CancellationToken class CancellationTokenTest : StringSpec() { diff --git a/nebulosa-common/src/test/kotlin/CountUpDownLatchTest.kt b/nebulosa-common/src/test/kotlin/CountUpDownLatchTest.kt index caa2580d6..5c96bf082 100644 --- a/nebulosa-common/src/test/kotlin/CountUpDownLatchTest.kt +++ b/nebulosa-common/src/test/kotlin/CountUpDownLatchTest.kt @@ -6,7 +6,7 @@ import io.kotest.matchers.longs.shouldBeGreaterThanOrEqual import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import nebulosa.common.concurrency.CountUpDownLatch +import nebulosa.common.concurrency.latch.CountUpDownLatch import java.util.concurrent.TimeUnit import kotlin.system.measureTimeMillis diff --git a/nebulosa-constants/src/main/kotlin/nebulosa/constants/Distance.kt b/nebulosa-constants/src/main/kotlin/nebulosa/constants/Distance.kt index a1c50471b..7a61b3c52 100644 --- a/nebulosa-constants/src/main/kotlin/nebulosa/constants/Distance.kt +++ b/nebulosa-constants/src/main/kotlin/nebulosa/constants/Distance.kt @@ -10,7 +10,7 @@ const val AU_M = 149597870700.0 /** * Astronomical unit (km, IAU 2012). */ -const val AU_KM = 149597870.700 +const val AU_KM = AU_M / 1000.0 /** * Speed of light (m/s). @@ -18,9 +18,9 @@ const val AU_KM = 149597870.700 const val SPEED_OF_LIGHT = 299792458.0 /** - * Light time (au per s). + * Light time for 1 AU in s. */ -const val LIGHT_TIME_AU_S = AU_M / SPEED_OF_LIGHT +const val LIGHT_TIME_AU = AU_M / SPEED_OF_LIGHT /** * Schwarzschild radius of the Sun (au). diff --git a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/AstrometryParameters.kt b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/AstrometryParameters.kt index 3d4b8934e..5f0550fc6 100644 --- a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/AstrometryParameters.kt +++ b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/AstrometryParameters.kt @@ -3,13 +3,14 @@ package nebulosa.erfa import nebulosa.math.Angle import nebulosa.math.Distance import nebulosa.math.Matrix3D +import nebulosa.math.Vector3D data class AstrometryParameters( @JvmField val pmt: Double = 0.0, // PM time interval (SSB, Julian years). - @JvmField val eb: CartesianCoordinate = CartesianCoordinate.ZERO, // SSB to observer (vector, au). - @JvmField val ehx: Double = 0.0, val ehy: Double = 0.0, val ehz: Double = 0.0, // Sun to observer (unit vector). + @JvmField val eb: Vector3D = Vector3D.EMPTY, // SSB to observer (vector, au). + @JvmField val eh: Vector3D = Vector3D.EMPTY, // Sun to observer (unit vector). @JvmField val em: Distance = 0.0, // Distance from Sun to observer. - @JvmField val vx: Double = 0.0, val vy: Double = 0.0, val vz: Double = 0.0, // Barycentric observer velocity (c) + @JvmField val v: Vector3D = Vector3D.EMPTY, // Barycentric observer velocity (c) @JvmField val bm1: Double = 0.0, // sqrt(1-|v|^2): reciprocal of Lorenz factor. @JvmField val bpn: Matrix3D = Matrix3D.IDENTITY, // Bias-precession-nutation matrix. @JvmField val along: Angle = 0.0, // Longitude + s' + dERA(DUT). diff --git a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/CartesianCoordinate.kt b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/CartesianCoordinate.kt index 68041f2b2..b255b309c 100644 --- a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/CartesianCoordinate.kt +++ b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/CartesianCoordinate.kt @@ -1,9 +1,6 @@ package nebulosa.erfa import nebulosa.math.* -import kotlin.math.abs -import kotlin.math.acos -import kotlin.math.hypot class CartesianCoordinate : Vector3D { @@ -13,37 +10,6 @@ class CartesianCoordinate : Vector3D { val spherical by lazy { SphericalCoordinate.of(x, y, z) } - fun angularDistance(coordinate: CartesianCoordinate): Angle { - val dot = x * coordinate.x + y * coordinate.y + z * coordinate.z - val norm0 = hypot(x, y) - val norm1 = hypot(coordinate.x, coordinate.y) - val v = dot / (norm0 * norm1) - return if (abs(v) > 1.0) if (v < 0.0) SEMICIRCLE else 0.0 - else acos(v).rad - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is CartesianCoordinate) return false - - if (x != other.x) return false - if (y != other.y) return false - if (z != other.z) return false - - return true - } - - override fun hashCode(): Int { - var result = x.hashCode() - result = 31 * result + y.hashCode() - result = 31 * result + z.hashCode() - return result - } - - override fun toString(): String { - return "CartesianCoordinate(x=$x, y=$y, z=$z)" - } - companion object { @JvmStatic val ZERO = CartesianCoordinate() @@ -54,11 +20,7 @@ class CartesianCoordinate : Vector3D { * to [CartesianCoordinate]. */ @JvmStatic - fun of( - theta: Angle, - phi: Angle, - r: Distance, - ): CartesianCoordinate { + fun of(theta: Angle, phi: Angle, r: Distance): CartesianCoordinate { val cp = phi.cos val x = r * (theta.cos * cp) val y = r * (theta.sin * cp) diff --git a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt index c5f76b18d..5d3001a9d 100644 --- a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt +++ b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt @@ -11,6 +11,10 @@ import okio.BufferedSource import kotlin.math.* import kotlin.math.PI +inline fun eraPdp(a: DoubleArray, b: DoubleArray): Double { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + /** * P-vector to spherical polar coordinates. * @@ -34,6 +38,16 @@ fun eraC2s(x: Distance, y: Distance, z: Distance): DoubleArray { return doubleArrayOf(theta.rad, phi.rad) } +/** + * Convert spherical coordinates to Cartesian. + * + * @return direction cosines. + */ +fun eraS2c(theta: Angle, phi: Angle): Vector3D { + val cp = cos(phi) + return Vector3D(cos(theta) * cp, sin(theta) * cp, sin(phi)) +} + /** * Apply aberration to transform natural direction into proper direction. * @@ -49,12 +63,20 @@ fun eraAb(pnat: Vector3D, v: Vector3D, s: Distance, bm1: Double): Vector3D { val w1 = 1.0 + pdv / (1.0 + bm1) val w2 = SCHWARZSCHILD_RADIUS_OF_THE_SUN / s val p = DoubleArray(3) + var r2 = 0.0 - for (i in 0..2) { - p[i] = pnat[i] * bm1 + w1 * v[i] + w2 * (v[i] - pdv * pnat[i]) + repeat(3) { + p[it] = pnat[it] * bm1 + w1 * v[it] + w2 * (v[it] - pdv * pnat[it]) + r2 += p[it] * p[it] } - return Vector3D(p).normalized + val r = sqrt(r2) + + repeat(3) { + p[it] /= r + } + + return Vector3D(p) } /** @@ -244,8 +266,8 @@ fun eraAnpm(angle: Angle): Angle { * i.e. around 1/298. */ fun eraGc2Gde( - radius: Distance, flattening: Double, - x: Distance, y: Distance, z: Distance, + radius: Double, flattening: Double, + x: Double, y: Double, z: Double, ): SphericalCoordinate { val aeps2 = radius * radius * 1e-32 val e2 = (2.0 - flattening) * flattening @@ -302,7 +324,7 @@ fun eraGc2Gde( height = absz - b } - return SphericalCoordinate(elong.rad, phi.rad, height.au) + return SphericalCoordinate(elong.rad, phi.rad, height) } /** @@ -313,9 +335,9 @@ fun eraGc2Gde( * i.e. around 1/298. */ fun eraGd2Gce( - radius: Distance, flattening: Double, - elong: Angle, phi: Angle, height: Distance, -): CartesianCoordinate { + radius: Double, flattening: Double, + elong: Angle, phi: Angle, height: Double, +): Vector3D { val sp = phi.sin val cp = phi.cos val w = (1.0 - flattening).let { it * it } @@ -331,7 +353,7 @@ fun eraGd2Gce( val y = r * elong.sin val z = (aS + height) * sp - return CartesianCoordinate(x.au, y.au, z.au) + return Vector3D(x, y, z) } /** @@ -384,17 +406,17 @@ inline fun eraPom00(xp: Angle, yp: Angle, sp: Angle): Matrix3D { * @return Position/velocity vector (m, m/s, CIRS) */ fun eraPvtob( - elong: Angle, phi: Angle, hm: Distance, + elong: Angle, phi: Angle, hm: Double, xp: Angle, yp: Angle, sp: Angle, theta: Angle, ): PositionAndVelocity { // Geodetic to geocentric transformation (WGS84). - val xyzm = eraGd2Gce(6378137.0.m, 1.0 / 298.257223563, elong, phi, hm) + val xyzm = eraGd2Gce(6378137.0, 1.0 / 298.257223563, elong, phi, hm) // Polar motion and TIO position. val rpm = eraPom00(xp, yp, sp) - val (x, y, z) = rpm.transposed * Vector3D(xyzm.x.toMeters, xyzm.y.toMeters, xyzm.z.toMeters) + val (x, y, z) = rpm.transposed * xyzm val s = theta.sin val c = theta.cos @@ -408,9 +430,6 @@ fun eraPvtob( return PositionAndVelocity(Vector3D(px, py, z), Vector3D(vx, vy, 0.0)) } -private const val AUDMS = AU_M / DAYSEC -private const val CR = LIGHT_TIME_AU_S / DAYSEC - /** * For an observer whose geocentric position and velocity are known, * prepare star-independent astrometry parameters for transformations @@ -419,79 +438,50 @@ private const val CR = LIGHT_TIME_AU_S / DAYSEC * * @param tdb1 TDB date * @param tdb2 TDB fraction date - * @param px Observer's geocentric position (m) - * @param py Observer's geocentric position (m) - * @param pz Observer's geocentric position (m) - * @param vx Observer's geocentric velocity (m/s) - * @param vy Observer's geocentric velocity (m/s) - * @param vz Observer's geocentric velocity (m/s) - * @param ebpx Earth barycentric position (au) - * @param ebpy Earth barycentric position (au) - * @param ebpz Earth barycentric position (au) - * @param ebvx Earth barycentric velocity (au/day) - * @param ebvy Earth barycentric velocity (au/day) - * @param ebvz Earth barycentric velocity (au/day) - * @param ehpx Earth heliocentric position (au) - * @param ehpy Earth heliocentric position (au) - * @param ehpz Earth heliocentric position (au) - */ -@Suppress("UnnecessaryVariable") + * @param p Observer's geocentric position (m) + * @param v Observer's geocentric velocity (m/s) + * @param ebp Earth barycentric position (au) + * @param ebv Earth barycentric velocity (au/day) + * @param ehp Earth heliocentric position (au) + */ fun eraApcs( tdb1: Double, tdb2: Double, - px: Distance, py: Distance, pz: Distance, - vx: Double, vy: Double, vz: Double, - ebpx: Distance, ebpy: Distance, ebpz: Distance, - ebvx: Velocity, ebvy: Velocity, ebvz: Velocity, - ehpx: Distance, ehpy: Distance, ehpz: Distance, + p: Vector3D, v: Vector3D, + ebp: Vector3D, ebv: Vector3D, ehp: Vector3D, ): AstrometryParameters { // Time since reference epoch, years (for proper motion calculation). val pmt = (tdb1 - J2000 + tdb2) / DAYSPERJY // Adjust Earth ephemeris to observer. - val dpx = px - val dvx = vx / AUDMS - val phx = ehpx + dpx - val vbx = ebvx + dvx - - val dpy = py - val dvy = vy / AUDMS - val vby = ebvy + dvy - val phy = ehpy + dpy - - val dpz = pz - val dvz = vz / AUDMS - val vbz = ebvz + dvz - val phz = ehpz + dpz + val ph = ehp + p / AU_M + val vb = ebv + v / (AU_M / DAYSEC) // Barycentric position of observer (au). - val pbx = ebpx + dpx - val pby = ebpy + dpy - val pbz = ebpz + dpz + val pb = ebp + p / AU_M // Heliocentric direction and distance (unit vector and au). - val ph = Vector3D(phx, phy, phz) val em = ph.length - val (ehx, ehy, ehz) = ph.normalized + val eh = ph.normalized // Barycentric vel. in units of c, and reciprocal of Lorenz factor. var v2 = 0.0 - val wx = vbx * CR + val wx = vb[0] * (LIGHT_TIME_AU / DAYSEC) v2 += wx * wx - val wy = vby * CR + val wy = vb[1] * (LIGHT_TIME_AU / DAYSEC) v2 += wy * wy - val wz = vbz * CR + val wz = vb[2] * (LIGHT_TIME_AU / DAYSEC) v2 += wz * wz val bm1 = sqrt(1.0 - v2) return AstrometryParameters( pmt = pmt, - eb = CartesianCoordinate(pbx, pby, pbz), + eb = pb, em = em.au, - ehx = ehx, ehy = ehy, ehz = ehz, - vx = wx, vy = wy, vz = wz, + eh = eh, + v = Vector3D(wx, wy, wz), bm1 = bm1, ) } @@ -504,16 +494,10 @@ fun eraApcs( * site coordinates. * * @param tdb1 TDB as a 2-part... - * @param tdb2 ...Julian Date (Note 1) - * @param ebpx Earth barycentric position (au) - * @param ebpy Earth barycentric position (au) - * @param ebpz Earth barycentric position (au) - * @param ebvx Earth barycentric velocity (au/day) - * @param ebvy Earth barycentric velocity (au/day) - * @param ebvz Earth barycentric velocity (au/day) - * @param ehpx Earth heliocentric position (au) - * @param ehpy Earth heliocentric position (au) - * @param ehpz Earth heliocentric position (au) + * @param tdb2 ...Julian Date + * @param ebp Earth barycentric position (au) + * @param ebv Earth barycentric velocity (au/day) + * @param ehp Earth heliocentric position (au) * @param x CIP X (components of unit vector) * @param y CIP Y (components of unit vector) * @param s The CIO locator s (radians) @@ -529,13 +513,10 @@ fun eraApcs( */ fun eraApco( tdb1: Double, tdb2: Double, - ebpx: Distance, ebpy: Distance, ebpz: Distance, - ebvx: Velocity, ebvy: Velocity, ebvz: Velocity, - ehpx: Distance, ehpy: Distance, ehpz: Distance, - x: Double, y: Double, - s: Angle, + ebp: Vector3D, ebv: Vector3D, ehp: Vector3D, + x: Double, y: Double, s: Angle, theta: Angle, elong: Angle, phi: Angle, - hm: Distance, + hm: Double, xp: Angle, yp: Angle, sp: Angle, refa: Angle, refb: Angle, @@ -544,16 +525,11 @@ fun eraApco( var r = Matrix3D.rotZ(theta + sp).rotateY(-xp).rotateX(-yp).rotateZ(elong) // Solve for local Earth rotation angle. - val a = r[0, 0] - val b = r[0, 1] - val eral = if (a != 0.0 || b != 0.0) atan2(b, a).rad else 0.0 + val eral = atan2(r[0, 1], r[0, 0]) // Solve for polar motion [X,Y] with respect to local meridian. - val c = r[0, 2] - val xpl = atan2(c, hypot(a, b)).rad - val d = r[1, 2] - val e = r[2, 2] - val ypl = if (d != 0.0 || e != 0.0) (-atan2(d, e)).rad else 0.0 + val xpl = atan2(r[0, 2], hypot(r[0, 0], r[0, 1])).rad + val ypl = -atan2(r[1, 2], r[2, 2]) // Adjusted longitude. val along = eraAnpm(eral - theta) @@ -576,11 +552,8 @@ fun eraApco( // ICRS <-> GCRS parameters. return eraApcs( tdb1, tdb2, - p.x.m, p.y.m, p.z.m, - v.x, v.y, v.z, - ebpx, ebpy, ebpz, - ebvx, ebvy, ebvz, - ehpx, ehpy, ehpz, + p, v, + ebp, ebv, ehp, ).copy( eral = eral, xpl = xpl, ypl = ypl, @@ -629,7 +602,7 @@ fun eraPfw06(tt1: Double, tt2: Double): FukushimaWilliamsFourAngles { /** * Fundamental argument, IERS Conventions (2003): mean anomaly of the Moon. * - * @param t TDB, Julian centuries since J2000.0 (Note 1) + * @param t TDB, Julian centuries since J2000.0 */ fun eraFal03(t: Double): Angle { return (485868.249036 + t * (1717915923.2178 + t * (31.8792 + t * (0.051635 + t * (-0.00024470))))).mod(TURNAS).arcsec @@ -638,7 +611,7 @@ fun eraFal03(t: Double): Angle { /** * Fundamental argument, IERS Conventions (2003): mean anomaly of the Sun. * - * @param t TDB, Julian centuries since J2000.0 (Note 1) + * @param t TDB, Julian centuries since J2000.0 */ fun eraFalp03(t: Double): Angle { return (1287104.793048 + t * (129596581.0481 + t * (-0.5532 + t * (0.000136 + t * (-0.00001149))))).mod(TURNAS).arcsec @@ -647,7 +620,7 @@ fun eraFalp03(t: Double): Angle { /** * Fundamental argument, IERS Conventions (2003): mean anomaly of the Sun. * - * @param t TDB, Julian centuries since J2000.0 (Note 1) + * @param t TDB, Julian centuries since J2000.0 */ fun eraFad03(t: Double): Angle { return (1072260.703692 + t * (1602961601.2090 + t * (-6.3706 + t * (0.006593 + t * (-0.00003169))))).mod(TURNAS).arcsec @@ -657,7 +630,7 @@ fun eraFad03(t: Double): Angle { * Fundamental argument, IERS Conventions (2003): mean longitude of the Moon * minus mean longitude of the ascending node. * - * @param t TDB, Julian centuries since J2000.0 (Note 1) + * @param t TDB, Julian centuries since J2000.0 */ fun eraFaf03(t: Double): Angle { return (335779.526232 + t * (1739527262.8478 + t * (-12.7512 + t * (-0.001037 + t * (0.00000417))))).mod(TURNAS).arcsec @@ -666,7 +639,7 @@ fun eraFaf03(t: Double): Angle { /** * Fundamental argument, IERS Conventions (2003): mean longitude of the Moon's ascending node. * - * @param t TDB, Julian centuries since J2000.0 (Note 1) + * @param t TDB, Julian centuries since J2000.0 */ fun eraFaom03(t: Double): Angle { return (450160.398036 + t * (-6962890.5431 + t * (7.4722 + t * (0.007702 + t * (-0.00005939))))).mod(TURNAS).arcsec @@ -675,56 +648,56 @@ fun eraFaom03(t: Double): Angle { /** * Fundamental argument, IERS Conventions (2003): general accumulated precession in longitude. * - * @param t TDB, Julian centuries since J2000.0 (Note 1) + * @param t TDB, Julian centuries since J2000.0 */ fun eraFapa03(t: Double) = ((0.024381750 + 0.00000538691 * t) * t).rad /** * Fundamental argument, IERS Conventions (2003): mean longitude of Mercury. * - * @param t TDB, Julian centuries since J2000.0 (Note 1) + * @param t TDB, Julian centuries since J2000.0 */ fun eraFame03(t: Double) = (4.402608842 + 2608.7903141574 * t).mod(TAU).rad /** * Fundamental argument, IERS Conventions (2003): mean longitude of Venus. * - * @param t TDB, Julian centuries since J2000.0 (Note 1) + * @param t TDB, Julian centuries since J2000.0 */ fun eraFave03(t: Double) = (3.176146697 + 1021.3285546211 * t).mod(TAU).rad /** * Fundamental argument, IERS Conventions (2003): mean longitude of Earth. * - * @param t TDB, Julian centuries since J2000.0 (Note 1) + * @param t TDB, Julian centuries since J2000.0 */ fun eraFae03(t: Double) = (1.753470314 + 628.3075849991 * t).mod(TAU).rad /** * Fundamental argument, IERS Conventions (2003): mean longitude of Mars. * - * @param t TDB, Julian centuries since J2000.0 (Note 1) + * @param t TDB, Julian centuries since J2000.0 */ fun eraFama03(t: Double) = (6.203480913 + 334.0612426700 * t).mod(TAU).rad /** * Fundamental argument, IERS Conventions (2003): mean longitude of Jupiter. * - * @param t TDB, Julian centuries since J2000.0 (Note 1) + * @param t TDB, Julian centuries since J2000.0 */ fun eraFaju03(t: Double) = (0.599546497 + 52.9690962641 * t).mod(TAU).rad /** * Fundamental argument, IERS Conventions (2003): mean longitude of Saturn. * - * @param t TDB, Julian centuries since J2000.0 (Note 1) + * @param t TDB, Julian centuries since J2000.0 */ fun eraFasa03(t: Double) = (0.874016757 + 21.3299104960 * t).mod(TAU).rad /** * Fundamental argument, IERS Conventions (2003): mean longitude of Uranus. * - * @param t TDB, Julian centuries since J2000.0 (Note 1) + * @param t TDB, Julian centuries since J2000.0 */ fun eraFaur03(t: Double) = (5.481293872 + 7.4781598567 * t).mod(TAU).rad @@ -846,8 +819,8 @@ fun eraNut00a(tt1: Double, tt2: Double): DoubleArray { for (i in xpl.indices.reversed()) { val arg = (xpl[i].nl * al + xpl[i].nf * af + xpl[i].nd * ad + xpl[i].nom * aom + xpl[i].nme * alme + - xpl[i].nve * alve + xpl[i].nea * alea + xpl[i].nma * alma + xpl[i].nju * alju + - xpl[i].nsa * alsa + xpl[i].nur * alur + xpl[i].nne * alne + xpl[i].npa * apa).mod(TAU) + xpl[i].nve * alve + xpl[i].nea * alea + xpl[i].nma * alma + xpl[i].nju * alju + + xpl[i].nsa * alsa + xpl[i].nur * alur + xpl[i].nne * alne + xpl[i].npa * apa).mod(TAU) val sarg = sin(arg) val carg = cos(arg) @@ -905,112 +878,115 @@ fun eraPnm06a(tt1: Double, tt2: Double): Matrix3D { } @Suppress("ArrayInDataClass") -private data class Term( +internal data class Term( @JvmField val nfa: IntArray, @JvmField val s: Double, @JvmField val c: Double, ) -// Polynomial coefficients -private val SP = doubleArrayOf(94.00e-6, 3808.65e-6, -122.68e-6, -72574.11e-6, 27.98e-6, 15.62e-6) - -// Terms of order t^0 -private val S0 = arrayOf( - // 1-10 - Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), -2640.73e-6, 0.39e-6), - Term(intArrayOf(0, 0, 0, 0, 2, 0, 0, 0), -63.53e-6, 0.02e-6), - Term(intArrayOf(0, 0, 2, -2, 3, 0, 0, 0), -11.75e-6, -0.01e-6), - Term(intArrayOf(0, 0, 2, -2, 1, 0, 0, 0), -11.21e-6, -0.01e-6), - Term(intArrayOf(0, 0, 2, -2, 2, 0, 0, 0), 4.57e-6, 0.00e-6), - Term(intArrayOf(0, 0, 2, 0, 3, 0, 0, 0), -2.02e-6, 0.00e-6), - Term(intArrayOf(0, 0, 2, 0, 1, 0, 0, 0), -1.98e-6, 0.00e-6), - Term(intArrayOf(0, 0, 0, 0, 3, 0, 0, 0), 1.72e-6, 0.00e-6), - Term(intArrayOf(0, 1, 0, 0, 1, 0, 0, 0), 1.41e-6, 0.01e-6), - Term(intArrayOf(0, 1, 0, 0, -1, 0, 0, 0), 1.26e-6, 0.01e-6), - // 11-20 - Term(intArrayOf(1, 0, 0, 0, -1, 0, 0, 0), 0.63e-6, 0.00e-6), - Term(intArrayOf(1, 0, 0, 0, 1, 0, 0, 0), 0.63e-6, 0.00e-6), - Term(intArrayOf(0, 1, 2, -2, 3, 0, 0, 0), -0.46e-6, 0.00e-6), - Term(intArrayOf(0, 1, 2, -2, 1, 0, 0, 0), -0.45e-6, 0.00e-6), - Term(intArrayOf(0, 0, 4, -4, 4, 0, 0, 0), -0.36e-6, 0.00e-6), - Term(intArrayOf(0, 0, 1, -1, 1, -8, 12, 0), 0.24e-6, 0.12e-6), - Term(intArrayOf(0, 0, 2, 0, 0, 0, 0, 0), -0.32e-6, 0.00e-6), - Term(intArrayOf(0, 0, 2, 0, 2, 0, 0, 0), -0.28e-6, 0.00e-6), - Term(intArrayOf(1, 0, 2, 0, 3, 0, 0, 0), -0.27e-6, 0.00e-6), - Term(intArrayOf(1, 0, 2, 0, 1, 0, 0, 0), -0.26e-6, 0.00e-6), - // 21-30 - Term(intArrayOf(0, 0, 2, -2, 0, 0, 0, 0), 0.21e-6, 0.00e-6), - Term(intArrayOf(0, 1, -2, 2, -3, 0, 0, 0), -0.19e-6, 0.00e-6), - Term(intArrayOf(0, 1, -2, 2, -1, 0, 0, 0), -0.18e-6, 0.00e-6), - Term(intArrayOf(0, 0, 0, 0, 0, 8, -13, -1), 0.10e-6, -0.05e-6), - Term(intArrayOf(0, 0, 0, 2, 0, 0, 0, 0), -0.15e-6, 0.00e-6), - Term(intArrayOf(2, 0, -2, 0, -1, 0, 0, 0), 0.14e-6, 0.00e-6), - Term(intArrayOf(0, 1, 2, -2, 2, 0, 0, 0), 0.14e-6, 0.00e-6), - Term(intArrayOf(1, 0, 0, -2, 1, 0, 0, 0), -0.14e-6, 0.00e-6), - Term(intArrayOf(1, 0, 0, -2, -1, 0, 0, 0), -0.14e-6, 0.00e-6), - Term(intArrayOf(0, 0, 4, -2, 4, 0, 0, 0), -0.13e-6, 0.00e-6), - // 31-33 - Term(intArrayOf(0, 0, 2, -2, 4, 0, 0, 0), 0.11e-6, 0.00e-6), - Term(intArrayOf(1, 0, -2, 0, -3, 0, 0, 0), -0.11e-6, 0.00e-6), - Term(intArrayOf(1, 0, -2, 0, -1, 0, 0, 0), -0.11e-6, 0.00e-6), -) +internal object IAU2006 { + + // Polynomial coefficients + @JvmField internal val SP = doubleArrayOf(94.00e-6, 3808.65e-6, -122.68e-6, -72574.11e-6, 27.98e-6, 15.62e-6) + + // Terms of order t^0 + @JvmField internal val S0 = arrayOf( + // 1-10 + Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), -2640.73e-6, 0.39e-6), + Term(intArrayOf(0, 0, 0, 0, 2, 0, 0, 0), -63.53e-6, 0.02e-6), + Term(intArrayOf(0, 0, 2, -2, 3, 0, 0, 0), -11.75e-6, -0.01e-6), + Term(intArrayOf(0, 0, 2, -2, 1, 0, 0, 0), -11.21e-6, -0.01e-6), + Term(intArrayOf(0, 0, 2, -2, 2, 0, 0, 0), 4.57e-6, 0.00e-6), + Term(intArrayOf(0, 0, 2, 0, 3, 0, 0, 0), -2.02e-6, 0.00e-6), + Term(intArrayOf(0, 0, 2, 0, 1, 0, 0, 0), -1.98e-6, 0.00e-6), + Term(intArrayOf(0, 0, 0, 0, 3, 0, 0, 0), 1.72e-6, 0.00e-6), + Term(intArrayOf(0, 1, 0, 0, 1, 0, 0, 0), 1.41e-6, 0.01e-6), + Term(intArrayOf(0, 1, 0, 0, -1, 0, 0, 0), 1.26e-6, 0.01e-6), + // 11-20 + Term(intArrayOf(1, 0, 0, 0, -1, 0, 0, 0), 0.63e-6, 0.00e-6), + Term(intArrayOf(1, 0, 0, 0, 1, 0, 0, 0), 0.63e-6, 0.00e-6), + Term(intArrayOf(0, 1, 2, -2, 3, 0, 0, 0), -0.46e-6, 0.00e-6), + Term(intArrayOf(0, 1, 2, -2, 1, 0, 0, 0), -0.45e-6, 0.00e-6), + Term(intArrayOf(0, 0, 4, -4, 4, 0, 0, 0), -0.36e-6, 0.00e-6), + Term(intArrayOf(0, 0, 1, -1, 1, -8, 12, 0), 0.24e-6, 0.12e-6), + Term(intArrayOf(0, 0, 2, 0, 0, 0, 0, 0), -0.32e-6, 0.00e-6), + Term(intArrayOf(0, 0, 2, 0, 2, 0, 0, 0), -0.28e-6, 0.00e-6), + Term(intArrayOf(1, 0, 2, 0, 3, 0, 0, 0), -0.27e-6, 0.00e-6), + Term(intArrayOf(1, 0, 2, 0, 1, 0, 0, 0), -0.26e-6, 0.00e-6), + // 21-30 + Term(intArrayOf(0, 0, 2, -2, 0, 0, 0, 0), 0.21e-6, 0.00e-6), + Term(intArrayOf(0, 1, -2, 2, -3, 0, 0, 0), -0.19e-6, 0.00e-6), + Term(intArrayOf(0, 1, -2, 2, -1, 0, 0, 0), -0.18e-6, 0.00e-6), + Term(intArrayOf(0, 0, 0, 0, 0, 8, -13, -1), 0.10e-6, -0.05e-6), + Term(intArrayOf(0, 0, 0, 2, 0, 0, 0, 0), -0.15e-6, 0.00e-6), + Term(intArrayOf(2, 0, -2, 0, -1, 0, 0, 0), 0.14e-6, 0.00e-6), + Term(intArrayOf(0, 1, 2, -2, 2, 0, 0, 0), 0.14e-6, 0.00e-6), + Term(intArrayOf(1, 0, 0, -2, 1, 0, 0, 0), -0.14e-6, 0.00e-6), + Term(intArrayOf(1, 0, 0, -2, -1, 0, 0, 0), -0.14e-6, 0.00e-6), + Term(intArrayOf(0, 0, 4, -2, 4, 0, 0, 0), -0.13e-6, 0.00e-6), + // 31-33 + Term(intArrayOf(0, 0, 2, -2, 4, 0, 0, 0), 0.11e-6, 0.00e-6), + Term(intArrayOf(1, 0, -2, 0, -3, 0, 0, 0), -0.11e-6, 0.00e-6), + Term(intArrayOf(1, 0, -2, 0, -1, 0, 0, 0), -0.11e-6, 0.00e-6), + ) -// Terms of order t^1 -private val S1 = arrayOf( - // 1 - 3 - Term(intArrayOf(0, 0, 0, 0, 2, 0, 0, 0), -0.07e-6, 3.57e-6), - Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), 1.73e-6, -0.03e-6), - Term(intArrayOf(0, 0, 2, -2, 3, 0, 0, 0), 0.00e-6, 0.48e-6), -) + // Terms of order t^1 + @JvmField internal val S1 = arrayOf( + // 1 - 3 + Term(intArrayOf(0, 0, 0, 0, 2, 0, 0, 0), -0.07e-6, 3.57e-6), + Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), 1.73e-6, -0.03e-6), + Term(intArrayOf(0, 0, 2, -2, 3, 0, 0, 0), 0.00e-6, 0.48e-6), + ) -// Terms of order t^2 -private val S2 = arrayOf( - // 1-10 - Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), 743.52e-6, -0.17e-6), - Term(intArrayOf(0, 0, 2, -2, 2, 0, 0, 0), 56.91e-6, 0.06e-6), - Term(intArrayOf(0, 0, 2, 0, 2, 0, 0, 0), 9.84e-6, -0.01e-6), - Term(intArrayOf(0, 0, 0, 0, 2, 0, 0, 0), -8.85e-6, 0.01e-6), - Term(intArrayOf(0, 1, 0, 0, 0, 0, 0, 0), -6.38e-6, -0.05e-6), - Term(intArrayOf(1, 0, 0, 0, 0, 0, 0, 0), -3.07e-6, 0.00e-6), - Term(intArrayOf(0, 1, 2, -2, 2, 0, 0, 0), 2.23e-6, 0.00e-6), - Term(intArrayOf(0, 0, 2, 0, 1, 0, 0, 0), 1.67e-6, 0.00e-6), - Term(intArrayOf(1, 0, 2, 0, 2, 0, 0, 0), 1.30e-6, 0.00e-6), - Term(intArrayOf(0, 1, -2, 2, -2, 0, 0, 0), 0.93e-6, 0.00e-6), - // 11-20 - Term(intArrayOf(1, 0, 0, -2, 0, 0, 0, 0), 0.68e-6, 0.00e-6), - Term(intArrayOf(0, 0, 2, -2, 1, 0, 0, 0), -0.55e-6, 0.00e-6), - Term(intArrayOf(1, 0, -2, 0, -2, 0, 0, 0), 0.53e-6, 0.00e-6), - Term(intArrayOf(0, 0, 0, 2, 0, 0, 0, 0), -0.27e-6, 0.00e-6), - Term(intArrayOf(1, 0, 0, 0, 1, 0, 0, 0), -0.27e-6, 0.00e-6), - Term(intArrayOf(1, 0, -2, -2, -2, 0, 0, 0), -0.26e-6, 0.00e-6), - Term(intArrayOf(1, 0, 0, 0, -1, 0, 0, 0), -0.25e-6, 0.00e-6), - Term(intArrayOf(1, 0, 2, 0, 1, 0, 0, 0), 0.22e-6, 0.00e-6), - Term(intArrayOf(2, 0, 0, -2, 0, 0, 0, 0), -0.21e-6, 0.00e-6), - Term(intArrayOf(2, 0, -2, 0, -1, 0, 0, 0), 0.20e-6, 0.00e-6), - // 21-25 - Term(intArrayOf(0, 0, 2, 2, 2, 0, 0, 0), 0.17e-6, 0.00e-6), - Term(intArrayOf(2, 0, 2, 0, 2, 0, 0, 0), 0.13e-6, 0.00e-6), - Term(intArrayOf(2, 0, 0, 0, 0, 0, 0, 0), -0.13e-6, 0.00e-6), - Term(intArrayOf(1, 0, 2, -2, 2, 0, 0, 0), -0.12e-6, 0.00e-6), - Term(intArrayOf(0, 0, 2, 0, 0, 0, 0, 0), -0.11e-6, 0.00e-6), -) + // Terms of order t^2 + @JvmField internal val S2 = arrayOf( + // 1-10 + Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), 743.52e-6, -0.17e-6), + Term(intArrayOf(0, 0, 2, -2, 2, 0, 0, 0), 56.91e-6, 0.06e-6), + Term(intArrayOf(0, 0, 2, 0, 2, 0, 0, 0), 9.84e-6, -0.01e-6), + Term(intArrayOf(0, 0, 0, 0, 2, 0, 0, 0), -8.85e-6, 0.01e-6), + Term(intArrayOf(0, 1, 0, 0, 0, 0, 0, 0), -6.38e-6, -0.05e-6), + Term(intArrayOf(1, 0, 0, 0, 0, 0, 0, 0), -3.07e-6, 0.00e-6), + Term(intArrayOf(0, 1, 2, -2, 2, 0, 0, 0), 2.23e-6, 0.00e-6), + Term(intArrayOf(0, 0, 2, 0, 1, 0, 0, 0), 1.67e-6, 0.00e-6), + Term(intArrayOf(1, 0, 2, 0, 2, 0, 0, 0), 1.30e-6, 0.00e-6), + Term(intArrayOf(0, 1, -2, 2, -2, 0, 0, 0), 0.93e-6, 0.00e-6), + // 11-20 + Term(intArrayOf(1, 0, 0, -2, 0, 0, 0, 0), 0.68e-6, 0.00e-6), + Term(intArrayOf(0, 0, 2, -2, 1, 0, 0, 0), -0.55e-6, 0.00e-6), + Term(intArrayOf(1, 0, -2, 0, -2, 0, 0, 0), 0.53e-6, 0.00e-6), + Term(intArrayOf(0, 0, 0, 2, 0, 0, 0, 0), -0.27e-6, 0.00e-6), + Term(intArrayOf(1, 0, 0, 0, 1, 0, 0, 0), -0.27e-6, 0.00e-6), + Term(intArrayOf(1, 0, -2, -2, -2, 0, 0, 0), -0.26e-6, 0.00e-6), + Term(intArrayOf(1, 0, 0, 0, -1, 0, 0, 0), -0.25e-6, 0.00e-6), + Term(intArrayOf(1, 0, 2, 0, 1, 0, 0, 0), 0.22e-6, 0.00e-6), + Term(intArrayOf(2, 0, 0, -2, 0, 0, 0, 0), -0.21e-6, 0.00e-6), + Term(intArrayOf(2, 0, -2, 0, -1, 0, 0, 0), 0.20e-6, 0.00e-6), + // 21-25 + Term(intArrayOf(0, 0, 2, 2, 2, 0, 0, 0), 0.17e-6, 0.00e-6), + Term(intArrayOf(2, 0, 2, 0, 2, 0, 0, 0), 0.13e-6, 0.00e-6), + Term(intArrayOf(2, 0, 0, 0, 0, 0, 0, 0), -0.13e-6, 0.00e-6), + Term(intArrayOf(1, 0, 2, -2, 2, 0, 0, 0), -0.12e-6, 0.00e-6), + Term(intArrayOf(0, 0, 2, 0, 0, 0, 0, 0), -0.11e-6, 0.00e-6), + ) -// Terms of order t^3 -private val S3 = arrayOf( - // 1-4 - Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), 0.30e-6, -23.42e-6), - Term(intArrayOf(0, 0, 2, -2, 2, 0, 0, 0), -0.03e-6, -1.46e-6), - Term(intArrayOf(0, 0, 2, 0, 2, 0, 0, 0), -0.01e-6, -0.25e-6), - Term(intArrayOf(0, 0, 0, 0, 2, 0, 0, 0), 0.00e-6, 0.23e-6), -) + // Terms of order t^3 + @JvmField internal val S3 = arrayOf( + // 1-4 + Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), 0.30e-6, -23.42e-6), + Term(intArrayOf(0, 0, 2, -2, 2, 0, 0, 0), -0.03e-6, -1.46e-6), + Term(intArrayOf(0, 0, 2, 0, 2, 0, 0, 0), -0.01e-6, -0.25e-6), + Term(intArrayOf(0, 0, 0, 0, 2, 0, 0, 0), 0.00e-6, 0.23e-6), + ) -// Terms of order t^4 -private val S4 = arrayOf( - // 1-1 - Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), -0.26e-6, -0.01e-6), -) + // Terms of order t^4 + @JvmField internal val S4 = arrayOf( + // 1-1 + Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), -0.26e-6, -0.01e-6), + ) -private val S = arrayOf(S0, S1, S2, S3, S4) + @JvmField internal val S = arrayOf(S0, S1, S2, S3, S4) +} /** * The CIO locator s, positioning the Celestial Intermediate Origin on @@ -1042,17 +1018,17 @@ fun eraS06(tt1: Double, tt2: Double, x: Double, y: Double): Angle { fa[7] = eraFapa03(t) // Evalutate s. - val w = DoubleArray(6) { SP[it] } + val w = DoubleArray(6) { IAU2006.SP[it] } - for (k in S.indices) { - for (i in S[k].indices.reversed()) { + for (k in IAU2006.S.indices) { + for (i in IAU2006.S[k].indices.reversed()) { var a = 0.0 - for (j in 0..7) { - a += S[k][i].nfa[j] * fa[j] + repeat(8) { + a += IAU2006.S[k][i].nfa[it] * fa[it] } - w[k] += S[k][i].s * sin(a) + S[k][i].c * cos(a) + w[k] += IAU2006.S[k][i].s * sin(a) + IAU2006.S[k][i].c * cos(a) } } @@ -1109,7 +1085,7 @@ fun era00(ut11: Double, ut12: Double): Angle { * * @return tan Z coefficient (radians) and tan^3 Z coefficient (radians) */ -fun eraRefco(phpa: Double, tc: Double, rh: Double, wl: Double): DoubleArray { +fun eraRefco(phpa: Pressure, tc: Temperature, rh: Double, wl: Double): DoubleArray { // Decide whether optical/IR or radio case: switch at 100 microns. val optic = wl <= 100.0 @@ -1171,30 +1147,15 @@ fun eraRefco(phpa: Double, tc: Double, rh: Double, wl: Double): DoubleArray { * * @param tdb1 TDB date * @param tdb2 TDB fraction date - * @param ebpx Earth barycentric position (au) - * @param ebpy Earth barycentric position (au) - * @param ebpz Earth barycentric position (au) - * @param ebvx Earth barycentric velocity (au/day) - * @param ebvy Earth barycentric velocity (au/day) - * @param ebvz Earth barycentric velocity (au/day) - * @param ehpx Earth heliocentric position (au) - * @param ehpy Earth heliocentric position (au) - * @param ehpz Earth heliocentric position (au) - */ -fun eraApcg( + * @param ebp Earth barycentric position (au) + * @param ebv Earth barycentric velocity (au/day) + * @param ehp Earth heliocentric position (au) + */ +inline fun eraApcg( tdb1: Double, tdb2: Double, - ebpx: Distance, ebpy: Distance, ebpz: Distance, - ebvx: Velocity, ebvy: Velocity, ebvz: Velocity, - ehpx: Distance, ehpy: Distance, ehpz: Distance, + ebp: Vector3D, ebv: Vector3D, ehp: Vector3D, ): AstrometryParameters { - return eraApcs( - tdb1, tdb2, - 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, - ebpx, ebpy, ebpz, - ebvx, ebvy, ebvz, - ehpx, ehpy, ehpz, - ) + return eraApcs(tdb1, tdb2, Vector3D.EMPTY, Vector3D.EMPTY, ebp, ebv, ehp) } private const val AM12 = 0.000000211284 @@ -1255,25 +1216,25 @@ fun eraEpv00(tdb1: Double, tdb2: Double): Pair { } /** - * Precession-nutation, IAU 2000 model: a multi-purpose function, + * Precession-nutation, IAU 2000 model: a multi-purpose function, * supporting classical (equinox-based) use directly and CIO-based * use indirectly. */ -fun eraPn00(tt1: Double, tt2: Double, dpsi: Angle, deps: Angle): PrecessionNutationMatrices { +fun eraPn00(tt1: Double, tt2: Double, dpsi: Angle, deps: Angle): PrecessionNutationAnglesAndMatrices { // IAU 2000 precession-rate adjustments. val (_, depspr) = eraPr00(tt1, tt2) @@ -2122,7 +2084,7 @@ fun eraPn00(tt1: Double, tt2: Double, dpsi: Angle, deps: Angle): PrecessionNutat // Bias-precession-nutation matrix (classical). val rbpn = rn * rbp - return PrecessionNutationMatrices(epsa, rb, rp, rbp, rn, rbpn) + return PrecessionNutationAnglesAndMatrices(dpsi, deps, epsa, rb, rp, rbp, rn, rbpn) } typealias StarDirectionCosines = DoubleArray @@ -2699,3 +2661,906 @@ inline fun eraTtUt1(tt1: Double, tt2: Double, ttMinusUt1: Double): DoubleArray { } const val DBL_EPSILON = 2.220446049250313E-16 + +/** + * For a terrestrial observer, prepare star-independent astrometry + * parameters for transformations between ICRS and geocentric CIRS + * coordinates. The Earth ephemeris and CIP/CIO are supplied by the + * caller. + * + * The parameters produced by this function are required in the + * parallax, light deflection, aberration, and bias-precession-nutation + * parts of the astrometric transformation chain. + * + * @param tdb1 TDB as a 2-part... + * @param tdb2 ...Julian Date (Note 1) + * @param ebp Earth barycentric position (au) + * @param ebv Earth barycentric velocity (au/day) + * @param ehp Earth heliocentric position (au) + * @param x CIP X (components of unit vector) + * @param y CIP Y (components of unit vector) + * @param s the CIO locator s (radians) + * + * @return Star-independent astrometry parameters. + */ +fun eraApci( + tdb1: Double, tdb2: Double, + ebp: Vector3D, ebv: Vector3D, ehp: Vector3D, + x: Double, y: Double, s: Double, +): AstrometryParameters { + val astrom = eraApcg(tdb1, tdb2, ebp, ebv, ehp) + val bpn = eraC2ixys(x, y, s) + return astrom.copy(bpn = bpn) +} + +/** + * For a terrestrial observer, prepare star-independent astrometry + * parameters for transformations between ICRS and geocentric CIRS + * coordinates. The caller supplies the date, and ERFA models are used + * to predict the Earth ephemeris and CIP/CIO. + * + * The parameters produced by this function are required in the + * parallax, light deflection, aberration, and bias-precession-nutation + * parts of the astrometric transformation chain. + * + * @return star-independent astrometry parameters and + * equation of the origins (ERA-GST, radians). + */ +fun eraApci13(tdb1: Double, tdb2: Double): Pair { + // Earth barycentric & heliocentric position/velocity (au, au/d). + val (ehpv, ebpv) = eraEpv00(tdb1, tdb2) + + // Form the equinox based BPN matrix, IAU 2006/2000A. + val r = eraPnm06a(tdb1, tdb2) + + // Extract CIP X,Y. + val (x, y) = eraBpn2xy(r) + + // Obtain CIO locator s. + val s = eraS06(tdb1, tdb2, x, y) + + // Compute the star-independent astrometry parameters. + val astrom = eraApci(tdb1, tdb2, ebpv.position, ebpv.velocity, ehpv.position, x, y, s) + + // Equation of the origins. + val eo = eraEors(r, s) + + return astrom to eo +} + +/** + * Transform ICRS star data, epoch J2000.0, to CIRS. + */ +fun eraAtci13( + rightAscension: Angle, declination: Angle, + pmRA: Angle, pmDEC: Angle, parallax: Angle, rv: Velocity, + tdb1: Double, tdb2: Double +): DoubleArray { + // The transformation parameters. + val (astrom, eo) = eraApci13(tdb1, tdb2) + // ICRS (epoch J2000.0) to CIRS. + val (ri, di) = eraAtciq(rightAscension, declination, pmRA, pmDEC, parallax, rv, astrom) + + return doubleArrayOf(ri, di, eo) +} + +/** + * Quick ICRS, epoch J2000.0, to CIRS transformation, given precomputed + * star-independent astrometry parameters. + * + * Use of this function is appropriate when efficiency is important and + * where many star positions are to be transformed for one date. The + * star-independent parameters can be obtained by calling one of the + * functions [eraApci13], [eraApcg13], [eraApco13] or [eraApcs13]. + * + * If the parallax and proper motions are zero the [eraAtciqz] function + * can be used instead. + * + * @return CIRS RA,Dec (radians) + */ +fun eraAtciq( + rightAscension: Angle, declination: Angle, + pmRA: Angle, pmDEC: Angle, parallax: Angle, rv: Velocity, + astrom: AstrometryParameters, +): DoubleArray { + // Proper motion and parallax, giving BCRS coordinate direction. + val pco = eraPmpx(rightAscension, declination, pmRA, pmDEC, parallax, rv, astrom.pmt, astrom.eb) + + // Light deflection by the Sun, giving BCRS natural direction. + val pnat = eraLdsun(pco, astrom.eh, astrom.em) + + // Aberration, giving GCRS proper direction. + val ppr = eraAb(pnat, astrom.v, astrom.em, astrom.bm1) + + // Bias-precession-nutation, giving CIRS proper direction. + val pi = astrom.bpn * ppr + + // CIRS RA,Dec. + val (ri, di) = eraC2s(pi[0], pi[1], pi[2]) + + return doubleArrayOf(ri.normalized, di) +} + +/** + * Quick ICRS to CIRS transformation, given precomputed star- + * independent astrometry parameters, and assuming zero parallax and + * proper motion. + * + * Use of this function is appropriate when efficiency is important and + * where many star positions are to be transformed for one date. The + * star-independent parameters can be obtained by calling one of the + * functions eraApci[13], eraApcg[13], eraApco[13] or eraApcs[13]. + * + * The corresponding function for the case of non-zero parallax and + * proper motion is [eraAtciq]. + * + * @return CIRS RA,Dec (radians) + */ +fun eraAtciqz(rightAscension: Angle, declination: Angle, astrom: AstrometryParameters): DoubleArray { + // BCRS coordinate direction (unit vector). + val pco = eraS2c(rightAscension, declination) + + // Light deflection by the Sun, giving BCRS natural direction. + val pnat = eraLdsun(pco, astrom.eh, astrom.em) + + // Aberration, giving GCRS proper direction. + val ppr = eraAb(pnat, astrom.v, astrom.em, astrom.bm1) + + // Bias-precession-nutation, giving CIRS proper direction. + val pi = astrom.bpn * ppr + + // CIRS RA,Dec. + val (ri, di) = eraC2s(pi[0], pi[1], pi[2]) + + return doubleArrayOf(ri.normalized, di) +} + +/** + * Deflection of starlight by the Sun. + * + * @param p Direction from observer to star (unit vector) + * @param e Direction from Sun to observer (unit vector) + * @param em Distance from Sun to observer (au) + * + * @return Observer to deflected star (unit vector) + */ +fun eraLdsun(p: Vector3D, e: Vector3D, em: Distance): Vector3D { + // Deflection limiter (smaller for distant observers). + val em2 = max(1.0, em * em) + return eraLd(1.0, p, p, e, em, 1e-6 / em2) +} + +/** + * Apply light deflection by a solar-system body, as part of + * transforming coordinate direction into natural direction. + * + * @param bm Mass of the gravitating body (solar masses) + * @param p Direction from observer to source (unit vector) + * @param q Direction from body to source (unit vector) + * @param e Direction from body to observer (unit vector) + * @param em Distance from body to observer (au) + * @param dlim Deflection limiter (Note 4) + * + * @return Observer to deflected source (unit vector) + */ +fun eraLd(bm: Double, p: Vector3D, q: Vector3D, e: Vector3D, em: Distance, dlim: Double): Vector3D { + val qpe = DoubleArray(3) { q[it] + e[it] } + val w = bm * SCHWARZSCHILD_RADIUS_OF_THE_SUN / em / max(q.dot(qpe), dlim) + // p x (e x q). + val eq = e.cross(q) + val peq = p.cross(eq) + + // Apply the deflection. + repeat(3) { + qpe[it] = p[it] + w * peq[it] + } + + return Vector3D(qpe) +} + +/** + * Proper motion and parallax. + * + * @param rightAscension ICRS RA at catalog epoch (radians) + * @param declination ICRS Dec at catalog epoch (radians) + * @param pmRA RA proper motion (radians/year, Note 1) + * @param pmDEC Dec proper motion (radians/year) + * @param parallax parallax (arcsec) + * @param rv radial velocity (km/s, +ve if receding) + * @param pmt proper motion time interval (SSB, Julian years) + * @param pob SSB to observer vector (au) + * + * @return Coordinate direction (BCRS unit vector) + */ +fun eraPmpx( + rightAscension: Angle, declination: Angle, + pmRA: Angle, pmDEC: Angle, parallax: Angle, rv: Velocity, + pmt: Double, pob: Vector3D +): Vector3D { + val p = DoubleArray(3) + val pm = DoubleArray(3) + + // Spherical coordinates to unit vector (and useful functions). + val sr = sin(rightAscension) + val cr = cos(rightAscension) + val sd = sin(declination) + val cd = cos(declination) + p[0] = cr * cd + p[1] = sr * cd + p[2] = sd + + // Proper motion time interval (y) including Roemer effect. + val dt = pmt + pob.dot(p) * (LIGHT_TIME_AU / DAYSEC / DAYSPERJY) + + // Space motion (radians per year). + val pxr = parallax * ASEC2RAD + val w = (DAYSEC * DAYSPERJM / AU_M) * rv * pxr + val pdz = pmDEC * p[2] + pm[0] = -pmRA * p[1] - pdz * cr + w * p[0] + pm[1] = pmRA * p[0] - pdz * sr + w * p[1] + pm[2] = pmDEC * cd + w * p[2] + + // Coordinate direction of star (unit vector, BCRS). + repeat(3) { + p[it] += dt * pm[it] - pxr * pob[it] + } + + return Vector3D(p).normalized +} + +/** + * Extract from the bias-precession-nutation matrix the X,Y coordinates + * of the Celestial Intermediate Pole. + */ +inline fun eraBpn2xy(rbpn: Matrix3D): DoubleArray { + return doubleArrayOf(rbpn[2, 0], rbpn[2, 1]) +} + +/** + * Precession-nutation, IAU 2000B model: a multi-purpose function, + * supporting classical (equinox-based) use directly and CIO-based + * use indirectly. + */ +fun eraPn00b(tt1: Double, tt2: Double): PrecessionNutationAnglesAndMatrices { + val (dpsi, deps) = eraNut00b(tt1, tt2) + val (_, _, epsa, rb, rp, rbp, rn, rbpn) = eraPn00(tt1, tt2, dpsi, deps) + return PrecessionNutationAnglesAndMatrices(dpsi, deps, epsa, rb, rp, rbp, rn, rbpn) +} + +/** + * Form the matrix of precession-nutation for a given date (including + * frame bias), equinox-based, IAU 2000B model. + */ +inline fun eraPnm00b(tt1: Double, tt2: Double): Matrix3D { + return eraPn00b(tt1, tt2).gcrsToTrue +} + +/** + * The CIO locator s, positioning the Celestial Intermediate Origin on + * the equator of the Celestial Intermediate Pole, using the IAU 2000B + * precession-nutation model. + */ +fun eraS00b(tt1: Double, tt2: Double): Angle { + // Bias-precession-nutation-matrix, IAU 2000B. + val rbpn = eraPnm00b(tt1, tt2) + + // Extract the CIP coordinates. + val (x, y) = eraBpn2xy(rbpn) + + // Compute the CIO locator s, given the CIP coordinates. + return eraS00(tt1, tt2, x, y) +} + +internal object IAU2000 { + + // Polynomial coefficients + @JvmField internal val SP = doubleArrayOf(94.00e-6, 3808.35e-6, -119.94e-6, -72574.09e-6, 27.70e-6, 15.61e-6) + + // Terms of order t^0 + @JvmField internal val S0 = arrayOf( + // 1-10 + Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), -2640.73e-6, 0.39e-6), + Term(intArrayOf(0, 0, 0, 0, 2, 0, 0, 0), -63.53e-6, 0.02e-6), + Term(intArrayOf(0, 0, 2, -2, 3, 0, 0, 0), -11.75e-6, -0.01e-6), + Term(intArrayOf(0, 0, 2, -2, 1, 0, 0, 0), -11.21e-6, -0.01e-6), + Term(intArrayOf(0, 0, 2, -2, 2, 0, 0, 0), 4.57e-6, 0.00e-6), + Term(intArrayOf(0, 0, 2, 0, 3, 0, 0, 0), -2.02e-6, 0.00e-6), + Term(intArrayOf(0, 0, 2, 0, 1, 0, 0, 0), -1.98e-6, 0.00e-6), + Term(intArrayOf(0, 0, 0, 0, 3, 0, 0, 0), 1.72e-6, 0.00e-6), + Term(intArrayOf(0, 1, 0, 0, 1, 0, 0, 0), 1.41e-6, 0.01e-6), + Term(intArrayOf(0, 1, 0, 0, -1, 0, 0, 0), 1.26e-6, 0.01e-6), + + // 11-20 + Term(intArrayOf(1, 0, 0, 0, -1, 0, 0, 0), 0.63e-6, 0.00e-6), + Term(intArrayOf(1, 0, 0, 0, 1, 0, 0, 0), 0.63e-6, 0.00e-6), + Term(intArrayOf(0, 1, 2, -2, 3, 0, 0, 0), -0.46e-6, 0.00e-6), + Term(intArrayOf(0, 1, 2, -2, 1, 0, 0, 0), -0.45e-6, 0.00e-6), + Term(intArrayOf(0, 0, 4, -4, 4, 0, 0, 0), -0.36e-6, 0.00e-6), + Term(intArrayOf(0, 0, 1, -1, 1, -8, 12, 0), 0.24e-6, 0.12e-6), + Term(intArrayOf(0, 0, 2, 0, 0, 0, 0, 0), -0.32e-6, 0.00e-6), + Term(intArrayOf(0, 0, 2, 0, 2, 0, 0, 0), -0.28e-6, 0.00e-6), + Term(intArrayOf(1, 0, 2, 0, 3, 0, 0, 0), -0.27e-6, 0.00e-6), + Term(intArrayOf(1, 0, 2, 0, 1, 0, 0, 0), -0.26e-6, 0.00e-6), + + // 21-30 + Term(intArrayOf(0, 0, 2, -2, 0, 0, 0, 0), 0.21e-6, 0.00e-6), + Term(intArrayOf(0, 1, -2, 2, -3, 0, 0, 0), -0.19e-6, 0.00e-6), + Term(intArrayOf(0, 1, -2, 2, -1, 0, 0, 0), -0.18e-6, 0.00e-6), + Term(intArrayOf(0, 0, 0, 0, 0, 8, -13, -1), 0.10e-6, -0.05e-6), + Term(intArrayOf(0, 0, 0, 2, 0, 0, 0, 0), -0.15e-6, 0.00e-6), + Term(intArrayOf(2, 0, -2, 0, -1, 0, 0, 0), 0.14e-6, 0.00e-6), + Term(intArrayOf(0, 1, 2, -2, 2, 0, 0, 0), 0.14e-6, 0.00e-6), + Term(intArrayOf(1, 0, 0, -2, 1, 0, 0, 0), -0.14e-6, 0.00e-6), + Term(intArrayOf(1, 0, 0, -2, -1, 0, 0, 0), -0.14e-6, 0.00e-6), + Term(intArrayOf(0, 0, 4, -2, 4, 0, 0, 0), -0.13e-6, 0.00e-6), + + // 31-33 + Term(intArrayOf(0, 0, 2, -2, 4, 0, 0, 0), 0.11e-6, 0.00e-6), + Term(intArrayOf(1, 0, -2, 0, -3, 0, 0, 0), -0.11e-6, 0.00e-6), + Term(intArrayOf(1, 0, -2, 0, -1, 0, 0, 0), -0.11e-6, 0.00e-6), + ) + + // Terms of order t^1 + @JvmField internal val S1 = arrayOf( + // 1 - 3 + Term(intArrayOf(0, 0, 0, 0, 2, 0, 0, 0), -0.07e-6, 3.57e-6), + Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), 1.71e-6, -0.03e-6), + Term(intArrayOf(0, 0, 2, -2, 3, 0, 0, 0), 0.00e-6, 0.48e-6), + ) + + // Terms of order t^2 + @JvmField internal val S2 = arrayOf( + // 1-10 + // 1-10 + Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), 743.53e-6, -0.17e-6), + Term(intArrayOf(0, 0, 2, -2, 2, 0, 0, 0), 56.91e-6, 0.06e-6), + Term(intArrayOf(0, 0, 2, 0, 2, 0, 0, 0), 9.84e-6, -0.01e-6), + Term(intArrayOf(0, 0, 0, 0, 2, 0, 0, 0), -8.85e-6, 0.01e-6), + Term(intArrayOf(0, 1, 0, 0, 0, 0, 0, 0), -6.38e-6, -0.05e-6), + Term(intArrayOf(1, 0, 0, 0, 0, 0, 0, 0), -3.07e-6, 0.00e-6), + Term(intArrayOf(0, 1, 2, -2, 2, 0, 0, 0), 2.23e-6, 0.00e-6), + Term(intArrayOf(0, 0, 2, 0, 1, 0, 0, 0), 1.67e-6, 0.00e-6), + Term(intArrayOf(1, 0, 2, 0, 2, 0, 0, 0), 1.30e-6, 0.00e-6), + Term(intArrayOf(0, 1, -2, 2, -2, 0, 0, 0), 0.93e-6, 0.00e-6), + + // 11-20 + Term(intArrayOf(1, 0, 0, -2, 0, 0, 0, 0), 0.68e-6, 0.00e-6), + Term(intArrayOf(0, 0, 2, -2, 1, 0, 0, 0), -0.55e-6, 0.00e-6), + Term(intArrayOf(1, 0, -2, 0, -2, 0, 0, 0), 0.53e-6, 0.00e-6), + Term(intArrayOf(0, 0, 0, 2, 0, 0, 0, 0), -0.27e-6, 0.00e-6), + Term(intArrayOf(1, 0, 0, 0, 1, 0, 0, 0), -0.27e-6, 0.00e-6), + Term(intArrayOf(1, 0, -2, -2, -2, 0, 0, 0), -0.26e-6, 0.00e-6), + Term(intArrayOf(1, 0, 0, 0, -1, 0, 0, 0), -0.25e-6, 0.00e-6), + Term(intArrayOf(1, 0, 2, 0, 1, 0, 0, 0), 0.22e-6, 0.00e-6), + Term(intArrayOf(2, 0, 0, -2, 0, 0, 0, 0), -0.21e-6, 0.00e-6), + Term(intArrayOf(2, 0, -2, 0, -1, 0, 0, 0), 0.20e-6, 0.00e-6), + + // 21-25 + Term(intArrayOf(0, 0, 2, 2, 2, 0, 0, 0), 0.17e-6, 0.00e-6), + Term(intArrayOf(2, 0, 2, 0, 2, 0, 0, 0), 0.13e-6, 0.00e-6), + Term(intArrayOf(2, 0, 0, 0, 0, 0, 0, 0), -0.13e-6, 0.00e-6), + Term(intArrayOf(1, 0, 2, -2, 2, 0, 0, 0), -0.12e-6, 0.00e-6), + Term(intArrayOf(0, 0, 2, 0, 0, 0, 0, 0), -0.11e-6, 0.00e-6), + ) + + // Terms of order t^3 + @JvmField internal val S3 = arrayOf( + // 1-4 + Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), 0.30e-6, -23.51e-6), + Term(intArrayOf(0, 0, 2, -2, 2, 0, 0, 0), -0.03e-6, -1.39e-6), + Term(intArrayOf(0, 0, 2, 0, 2, 0, 0, 0), -0.01e-6, -0.24e-6), + Term(intArrayOf(0, 0, 0, 0, 2, 0, 0, 0), 0.00e-6, 0.22e-6), + ) + + // Terms of order t^4 + @JvmField internal val S4 = arrayOf( + // 1-1 + Term(intArrayOf(0, 0, 0, 0, 1, 0, 0, 0), -0.26e-6, -0.01e-6), + ) + + @JvmField internal val S = arrayOf(S0, S1, S2, S3, S4) +} + +/** + * The CIO locator s, positioning the Celestial Intermediate Origin on + * the equator of the Celestial Intermediate Pole, given the CIP's X,Y + * coordinates. Compatible with IAU 2000A precession-nutation. + */ +fun eraS00(tt1: Double, tt2: Double, x: Double, y: Double): Angle { + // Interval between fundamental epoch J2000.0 and current date (JC). + val t = (tt1 - J2000 + tt2) / DAYSPERJC + + // Fundamental Arguments (from IERS Conventions 2003) + val fa = DoubleArray(8) + + // Mean anomaly of the Moon. + fa[0] = eraFal03(t) + // Mean anomaly of the Sun. + fa[1] = eraFalp03(t) + // Mean longitude of the Moon minus that of the ascending node. + fa[2] = eraFaf03(t) + // Mean elongation of the Moon from the Sun. + fa[3] = eraFad03(t) + // Mean longitude of the ascending node of the Moon. + fa[4] = eraFaom03(t) + // Mean longitude of Venus. + fa[5] = eraFave03(t) + // Mean longitude of Earth. + fa[6] = eraFae03(t) + // General precession in longitude. + fa[7] = eraFapa03(t) + + // Evalutate s. + val w = DoubleArray(6) { IAU2000.SP[it] } + + for (k in IAU2000.S.indices) { + for (i in IAU2000.S[k].indices.reversed()) { + var a = 0.0 + + repeat(8) { + a += IAU2000.S[k][i].nfa[it] * fa[it] + } + + w[k] += IAU2000.S[k][i].s * sin(a) + IAU2000.S[k][i].c * cos(a) + } + } + + return (w[0] + (w[1] + (w[2] + (w[3] + (w[4] + w[5] * t) * t) * t) * t) * t).arcsec - x * y / 2.0 +} + +/** + * Form the matrix of precession-nutation for a given date (including + * frame bias), equinox based, IAU 2000A model. + */ +inline fun eraPnm00a(tt1: Double, tt2: Double): Matrix3D { + return eraPn00a(tt1, tt2).gcrsToTrue +} + +/** + * Precession-nutation, IAU 2000A model: a multi-purpose function, + * supporting classical (equinox-based) use directly and CIO-based + * use indirectly. + */ +fun eraPn00a(tt1: Double, tt2: Double): PrecessionNutationAnglesAndMatrices { + val (dpsi, deps) = eraNut00a(tt1, tt2) + val (_, _, epsa, rb, rp, rbp, rn, rbpn) = eraPn00(tt1, tt2, dpsi, deps) + return PrecessionNutationAnglesAndMatrices(dpsi, deps, epsa, rb, rp, rbp, rn, rbpn) +} + +/** + * The CIO locator s, positioning the Celestial Intermediate Origin on + * the equator of the Celestial Intermediate Pole, using the IAU 2000A + * precession-nutation model. + */ +fun eraS00a(tt1: Double, tt2: Double): Angle { + // Bias-precession-nutation-matrix, IAU 2000A. + val rbpn = eraPnm00a(tt1, tt2) + + // Extract the CIP coordinates. + val (x, y) = eraBpn2xy(rbpn) + + // Compute the CIO locator s, given the CIP coordinates. + return eraS00(tt1, tt2, x, y) +} + +/** + * For a terrestrial observer, prepare star-independent astrometry + * parameters for transformations between ICRS and observed + * coordinates. The caller supplies UTC, site coordinates, ambient air + * conditions and observing wavelength, and ERFA models are used to + * obtain the Earth ephemeris, CIP/CIO and refraction constants. + * + * The parameters produced by this function are required in the + * parallax, light deflection, aberration, and bias-precession-nutation + * parts of the ICRS/CIRS transformations. + * + * @param utc1 UTC as a 2-part... + * @param utc2 ...Julian Date + * @param dut1 UT1-UTC (seconds) + * @param elong Longitude (radians, east +ve) + * @param phi Latitude (geodetic, radians) + * @param hm Height above ellipsoid (m, geodetic) + * @param xp Polar motion coordinates (radians) + * @param yp Polar motion coordinates (radians) + * @param phpa Pressure at the observer (hPa = mBar) + * @param tc Ambient temperature at the observer (deg C) + * @param rh Relative humidity at the observer (range 0-1) + * @param wl Wavelength (micrometers) + */ +fun eraApco13( + utc1: Double, utc2: Double, dut1: Double, + elong: Angle, phi: Angle, hm: Distance, xp: Angle, yp: Angle, + phpa: Pressure, tc: Temperature, rh: Double, wl: Double +): Pair { + val (tai1, tai2) = eraUtcTai(utc1, utc2) + val (tt1, tt2) = eraTaiTt(tai1, tai2) + val (ut11, ut12) = eraUtcUt1(utc1, utc2, dut1) + + // Earth barycentric & heliocentric position/velocity (au, au/d). + val (ehpv, ebpv) = eraEpv00(tt1, tt2) + val (ebp, ebv) = ebpv + val ehp = ehpv.position + + // Form the equinox based BPN matrix, IAU 2006/2000A. + val r = eraPnm06a(tt1, tt2) + + // Extract CIP X,Y. + val (x, y) = eraBpn2xy(r) + + // Obtain CIO locator s. + val s = eraS06(tt1, tt2, x, y) + + // Earth rotation angle. + val theta = eraEra00(ut11, ut12) + + // TIO locator s'. + val sp = eraSp00(tt1, tt2) + + // Refraction constants A and B. + val (refa, refb) = eraRefco(phpa, tc, rh, wl) + + // Compute the star-independent astrometry parameters. + val astrom = eraApco( + tt1, tt2, ebp, ebv, ehp, + x, y, s, theta, + elong, phi, hm, xp, yp, sp, refa, refb + ) + + // Equation of the origins. + val eo = eraEors(r, s) + + return astrom to eo +} + +/** + * For an observer whose geocentric position and velocity are known, + * prepare star-independent astrometry parameters for transformations + * between ICRS and GCRS. The Earth ephemeris is from ERFA models. + * + * The parameters produced by this function are required in the space + * motion, parallax, light deflection and aberration parts of the + * astrometric transformation chain. + * + * @param p observer's geocentric position with respect to BCRS axes (m) + * @param v observer's geocentric velocity with respect to BCRS axes (m/s) + */ +fun eraApcs13(tdb1: Double, tdb2: Double, p: Vector3D, v: Vector3D): AstrometryParameters { + // Earth barycentric & heliocentric position/velocity (au, au/d). + val (ehpv, ebpv) = eraEpv00(tdb1, tdb2) + // Compute the star-independent astrometry parameters. + return eraApcs(tdb1, tdb2, p, v, ebpv.position, ebpv.velocity, ehpv.position) +} + +/** + * For a terrestrial observer, prepare star-independent astrometry + * parameters for transformations between CIRS and observed + * coordinates. The caller supplies the Earth orientation information + * and the refraction constants as well as the site coordinates. + * + * @param sp The TIO locator s (radians) + * @param theta Earth rotation angle (radians) + * @param elong Longitude (radians, east +ve) + * @param phi Latitude (geodetic, radians) + * @param hm Height above ellipsoid (m, geodetic) + * @param xp Polar motion coordinates (radians) + * @param yp Polar motion coordinates (radians) + * @param refa Refraction constant A (radians) + * @param refb Refraction constant B (radians) + */ +fun eraApio( + sp: Angle, theta: Angle, elong: Angle, phi: Angle, + hm: Double, xp: Angle, yp: Angle, refa: Angle, refb: Angle, + astrom: AstrometryParameters? = null, +): AstrometryParameters { + // Form the rotation matrix, CIRS to apparent [HA,Dec]. + val r = Matrix3D.rotZ(theta + sp).rotateY(-xp).rotateX(-yp).rotateZ(elong) + + // Solve for local Earth rotation angle. + val eral = atan2(r[0, 1], r[0, 0]) + + // Solve for polar motion [X,Y] with respect to local meridian. + val xpl = atan2(r[0, 2], hypot(r[0, 0], r[0, 1])) + val ypl = -atan2(r[1, 2], r[2, 2]) + + // Adjusted longitude. + val along = eraAnpm(eral - theta) + + // Functions of latitude. + val sphi = sin(phi) + val cphi = cos(phi) + + // Observer's geocentric position and velocity (m, m/s, CIRS). + val pv = eraPvtob(elong, phi, hm, xp, yp, sp, theta) + + // Magnitude of diurnal aberration vector. + val diurab = hypot(pv.velocity[0], pv.velocity[1]) / SPEED_OF_LIGHT + + // Refraction constants. + return astrom?.copy( + xpl = xpl, ypl = ypl, along = along, sphi = sphi, cphi = cphi, + diurab = diurab, refa = refa, refb = refb, eral = eral, + ) ?: AstrometryParameters( + xpl = xpl, ypl = ypl, along = along, sphi = sphi, cphi = cphi, + diurab = diurab, refa = refa, refb = refb, eral = eral, + ) +} + +/** + * For a terrestrial observer, prepare star-independent astrometry + * parameters for transformations between CIRS and observed + * coordinates. The caller supplies UTC, site coordinates, ambient air + * conditions and observing wavelength. + * + * @param utc1 UTC as a 2-part... + * @param utc2 ...Julian Date + * @param dut1 UT1-UTC (seconds) + * @param elong Longitude (radians, east +ve) + * @param phi Latitude (geodetic, radians) + * @param hm Height above ellipsoid (m, geodetic) + * @param xp Polar motion coordinates (radians) + * @param yp Polar motion coordinates (radians) + * @param phpa Pressure at the observer (hPa = mBar) + * @param tc Ambient temperature at the observer (deg C) + * @param rh Relative humidity at the observer (range 0-1) + * @param wl Wavelength (micrometers) + */ +fun eraApio13( + utc1: Double, utc2: Double, dut1: Double, + elong: Angle, phi: Angle, hm: Distance, xp: Angle, yp: Angle, + phpa: Pressure, tc: Temperature, rh: Double, wl: Double +): AstrometryParameters { + val (tai1, tai2) = eraUtcTai(utc1, utc2) + val (tt1, tt2) = eraTaiTt(tai1, tai2) + val (ut11, ut12) = eraUtcUt1(utc1, utc2, dut1) + + // TIO locator s'. + val sp = eraSp00(tt1, tt2) + + // Earth rotation angle. + val theta = eraEra00(ut11, ut12) + + // Refraction constants A and B. + val (refa, refb) = eraRefco(phpa, tc, rh, wl) + + // CIRS <-> observed astrometry parameters. + return eraApio(sp, theta, elong, phi, hm, xp, yp, refa, refb) +} + +/** + * Quick CIRS to observed place transformation. + * + * Use of this function is appropriate when efficiency is important and + * where many star positions are all to be transformed for one date. + * The star-independent astrometry parameters can be obtained by + * calling [eraApio13] or [eraApco13]. + * + * @param rightAscension CIRS right ascension + * @param declination CIRS declination + * @param astrom star-independent astrometry parameters. + * + * @return observed azimuth (radians: N=0,E=90), observed zenith distance (radians), + * observed hour angle (radians), observed declination (radians), observed right ascension (CIO-based, radians) + */ +fun eraAtioq(rightAscension: Angle, declination: Angle, astrom: AstrometryParameters): DoubleArray { + // CIRS RA,Dec to Cartesian -HA,Dec. + val v = eraS2c(rightAscension - astrom.eral, declination) + + // Polar motion. + val sx = sin(astrom.xpl) + val cx = cos(astrom.xpl) + val sy = sin(astrom.ypl) + val cy = cos(astrom.ypl) + val xhd = cx * v[0] + sx * v[2] + val yhd = sx * sy * v[0] + cy * v[1] - cx * sy * v[2] + val zhd = -sx * cy * v[0] + sy * v[1] + cx * cy * v[2] + + // Diurnal aberration. + var f = (1.0 - astrom.diurab * yhd) + val xhdt = f * xhd + val yhdt = f * (yhd + astrom.diurab) + val zhdt = f * zhd + + // Cartesian -HA,Dec to Cartesian Az,El (S=0,E=90). + val xaet = astrom.sphi * xhdt - astrom.cphi * zhdt + val zaet = astrom.cphi * xhdt + astrom.sphi * zhdt + + // Azimuth (N=0,E=90). + val azobs = if (xaet != 0.0 || yhdt != 0.0) atan2(yhdt, -xaet) else 0.0 + + // Cosine and sine of altitude, with precautions. + val r = max(sqrt(xaet * xaet + yhdt * yhdt), 1e-6) + val z = max(zaet, 0.05) + + // A*tan(z)+B*tan^3(z) model, with Newton-Raphson correction. + val tz = r / z + val w = astrom.refb * tz * tz + val del = (astrom.refa + w) * tz / (1.0 + (astrom.refa + 3.0 * w) / (z * z)) + + // Apply the change, giving observed vector. + val cosdel = 1.0 - del * del / 2.0 + f = cosdel - del * z / r + val xaeo = xaet * f + val yaeo = yhdt * f + val zaeo = cosdel * zaet + del * r + + // Observed ZD. + val zdobs = atan2(sqrt(xaeo * xaeo + yaeo * yaeo), zaeo) + + // Az/El vector to HA,Dec vector (both right-handed). + val vx = astrom.sphi * xaeo + astrom.cphi * zaeo + val vz = -astrom.cphi * xaeo + astrom.sphi * zaeo + + // To spherical -HA,Dec. + val (hmobs, dcobs) = eraC2s(vx, yaeo, vz) + + // Right ascension (with respect to CIO). + val raobs = astrom.eral + hmobs + + // Return the results. + return doubleArrayOf(azobs.normalized, zdobs, -hmobs, dcobs, raobs.normalized) +} + +/** + * ICRS RA,Dec to observed place. The caller supplies UTC, site + * coordinates, ambient air conditions and observing wavelength. + * + * ERFA models are used for the Earth ephemeris, bias-precession- + * nutation, Earth orientation and refraction. + * + * @param rightAscension ICRS RA at catalog epoch (radians) + * @param declination ICRS Dec at catalog epoch (radians) + * @param pmRA RA proper motion (radians/year, Note 1) + * @param pmDEC Dec proper motion (radians/year) + * @param parallax parallax (arcsec) + * @param rv radial velocity (km/s, +ve if receding) + * @param utc1 UTC as a 2-part... + * @param utc2 ...Julian Date + * @param dut1 UT1-UTC (seconds) + * @param elong Longitude (radians, east +ve) + * @param phi Latitude (geodetic, radians) + * @param hm Height above ellipsoid (m, geodetic) + * @param xp Polar motion coordinates (radians) + * @param yp Polar motion coordinates (radians) + * @param phpa Pressure at the observer (hPa = mBar) + * @param tc Ambient temperature at the observer (deg C) + * @param rh Relative humidity at the observer (range 0-1) + * @param wl Wavelength (micrometers) + * + * @return observed azimuth (radians: N=0,E=90), observed zenith distance (radians), + * observed hour angle (radians), observed declination (radians), + * observed right ascension (CIO-based, radians) and equation of the origins (ERA-GST, radians). + */ +fun eraAtco13( + rightAscension: Angle, declination: Angle, + pmRA: Angle, pmDEC: Angle, parallax: Angle, rv: Velocity, + utc1: Double, utc2: Double, dut1: Double, + elong: Angle, phi: Angle, hm: Double, xp: Angle, yp: Angle, + phpa: Double, tc: Temperature, rh: Double, wl: Double, +): Pair { + // Star-independent astrometry parameters. + val (astrom, eo) = eraApco13(utc1, utc2, dut1, elong, phi, hm, xp, yp, phpa, tc, rh, wl) + // Transform ICRS to CIRS. + val (ri, di) = eraAtciq(rightAscension, declination, pmRA, pmDEC, parallax, rv, astrom) + // Transform CIRS to observed. + return eraAtioq(ri, di, astrom) to eo +} + +/** + * Quick CIRS RA,Dec to ICRS astrometric place, given the star- + * independent astrometry parameters. + * + * Use of this function is appropriate when efficiency is important and + * where many star positions are all to be transformed for one date. + * The star-independent astrometry parameters can be obtained by + * calling one of the functions [eraApci13], [eraApcg13], [eraApco13] + * or [eraApcs13]. + * + * @return ICRS astrometric RA,Dec (radians) + */ +fun eraAticq(rightAscension: Angle, declination: Angle, astrom: AstrometryParameters): DoubleArray { + // CIRS RA,Dec to Cartesian. + val pi = eraS2c(rightAscension, declination) + + // Bias-precession-nutation, giving GCRS proper direction. + val ppr = astrom.bpn.transposed * pi + + // Aberration, giving GCRS natural direction + val d = DoubleArray(3) + val pnat = Vector3D() + + repeat(3) { + pnat.unsafe { + var r2 = 0.0 + + for (i in 0..2) { + val w = ppr[i] - d[i] + this[i] = w + r2 += w * w + } + + val r = sqrt(r2) + + for (i in 0..2) { + this[i] /= r + } + } + + val after = eraAb(pnat, astrom.v, astrom.em, astrom.bm1) + + pnat.unsafe { + var r2 = 0.0 + + for (i in 0..2) { + d[i] = after[i] - this[i] + val w = ppr[i] - d[i] + this[i] = w + r2 += w * w + } + + val r = sqrt(r2) + + for (i in 0..2) { + this[i] /= r + } + } + } + + // Light deflection by the Sun, giving BCRS coordinate direction. + d.fill(0.0) + + val pco = Vector3D() + + repeat(5) { + pco.unsafe { + var r2 = 0.0 + + for (i in 0..2) { + val w = pnat[i] - d[i] + this[i] = w + r2 += w * w + } + + val r = sqrt(r2) + + for (i in 0..2) { + this[i] /= r + } + } + + val after = eraLdsun(pco, astrom.eh, astrom.em) + + pco.unsafe { + var r2 = 0.0 + + for (i in 0..2) { + d[i] = after[i] - this[i] + val w = pnat[i] - d[i] + this[i] = w + r2 += w * w + } + + val r = sqrt(r2) + + for (i in 0..2) { + this[i] /= r + } + } + } + + return eraC2s(pco[0], pco[1], pco[2]) +} + +/** + * Transform star RA,Dec from geocentric CIRS to ICRS astrometric. + * + * @return ICRS astrometric RA,Dec (radians) and equation of the origins (ERA-GST, radians). + */ +fun eraAtic13(rightAscension: Angle, declination: Angle, tdb1: Double, tdb2: Double): DoubleArray { + // Star-independent astrometry parameters. + val (astrom, eo) = eraApci13(tdb1, tdb2) + + // CIRS to ICRS astrometric. + val (ri, di) = eraAticq(rightAscension, declination, astrom) + + return doubleArrayOf(ri, di, eo) +} diff --git a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/PrecessionNutationAnglesAndMatrices.kt b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/PrecessionNutationAnglesAndMatrices.kt new file mode 100644 index 000000000..339adf701 --- /dev/null +++ b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/PrecessionNutationAnglesAndMatrices.kt @@ -0,0 +1,14 @@ +package nebulosa.erfa + +import nebulosa.math.Angle +import nebulosa.math.Matrix3D + +data class PrecessionNutationAnglesAndMatrices( + @JvmField val dpsi: Angle, @JvmField val deps: Angle, // nutation + @JvmField val meanObliquity: Angle, // epsa + @JvmField val frameBias: Matrix3D, // rb + @JvmField val precession: Matrix3D, // rp + @JvmField val biasPrecession: Matrix3D, // rbp + @JvmField val nutation: Matrix3D, // rn + @JvmField val gcrsToTrue: Matrix3D, // rbpn +) diff --git a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/PrecessionNutationMatrices.kt b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/PrecessionNutationMatrices.kt deleted file mode 100644 index 30908f6bb..000000000 --- a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/PrecessionNutationMatrices.kt +++ /dev/null @@ -1,13 +0,0 @@ -package nebulosa.erfa - -import nebulosa.math.Angle -import nebulosa.math.Matrix3D - -data class PrecessionNutationMatrices( - @JvmField val meanObliquity: Angle, - @JvmField val frameBias: Matrix3D, - @JvmField val precession: Matrix3D, - @JvmField val biasPrecession: Matrix3D, - @JvmField val nutation: Matrix3D, - @JvmField val gcrsToTrue: Matrix3D, -) diff --git a/nebulosa-erfa/src/test/kotlin/ErfaTest.kt b/nebulosa-erfa/src/test/kotlin/ErfaTest.kt index 324e167a9..12ab8d55d 100644 --- a/nebulosa-erfa/src/test/kotlin/ErfaTest.kt +++ b/nebulosa-erfa/src/test/kotlin/ErfaTest.kt @@ -81,6 +81,12 @@ class ErfaTest : StringSpec() { theta shouldBe (-0.4636476090008061162 plusOrMinus 1e-14) phi shouldBe (0.2199879773954594463 plusOrMinus 1e-14) } + "eraS2c" { + val c = eraS2c(3.0123, -0.999) + c[0] shouldBe (-0.5366267667260523906 plusOrMinus 1e-12) + c[1] shouldBe (0.0697711109765145365 plusOrMinus 1e-12) + c[2] shouldBe (-0.8409302618566214041 plusOrMinus 1e-12) + } "eraP2s" { val (theta, phi, r) = eraP2s(100.0.au, (-50.0).au, 25.0.au) theta shouldBe (-0.4636476090008061162 plusOrMinus 1e-12) @@ -91,36 +97,36 @@ class ErfaTest : StringSpec() { eraAnpm((-4.0).rad) shouldBe (2.283185307179586477 plusOrMinus 1e-12) } "eraGc2Gde" { - val (e1, p1, h1) = eraGc2Gde(6378137.0.m, 1.0 / 298.257223563, 2e6.m, 3e6.m, 5.244e6.m) + val (e1, p1, h1) = eraGc2Gde(6378137.0, 1.0 / 298.257223563, 2e6, 3e6, 5.244e6) e1 shouldBe (0.9827937232473290680 plusOrMinus 1e-14) p1 shouldBe (0.97160184819075459 plusOrMinus 1e-14) - h1.toMeters shouldBe (331.4172461426059892 plusOrMinus 1e-8) + h1 shouldBe (331.4172461426059892 plusOrMinus 1e-8) - val (e2, p2, h2) = eraGc2Gde(6378137.0.m, 1.0 / 298.257222101, 2e6.m, 3e6.m, 5.244e6.m) + val (e2, p2, h2) = eraGc2Gde(6378137.0, 1.0 / 298.257222101, 2e6, 3e6, 5.244e6) e2 shouldBe (0.9827937232473290680 plusOrMinus 1e-14) p2 shouldBe (0.97160184820607853 plusOrMinus 1e-14) - h2.toMeters shouldBe (331.41731754844348 plusOrMinus 1e-8) + h2 shouldBe (331.41731754844348 plusOrMinus 1e-8) - val (e3, p3, h3) = eraGc2Gde(6378135.0.m, 1.0 / 298.26, 2e6.m, 3e6.m, 5.244e6.m) + val (e3, p3, h3) = eraGc2Gde(6378135.0, 1.0 / 298.26, 2e6, 3e6, 5.244e6) e3 shouldBe (0.9827937232473290680 plusOrMinus 1e-14) p3 shouldBe (0.9716018181101511937 plusOrMinus 1e-14) - h3.toMeters shouldBe (333.2770726130318123 plusOrMinus 1e-8) + h3 shouldBe (333.2770726130318123 plusOrMinus 1e-8) } "eraGd2Gc" { - val (x1, y1, z1) = eraGd2Gce(6378137.0.m, 1.0 / 298.257223563, 3.1.rad, (-0.5).rad, 2500.0.m) - x1.toMeters shouldBe (-5599000.5577049947 plusOrMinus 1e-7) - y1.toMeters shouldBe (233011.67223479203 plusOrMinus 1e-7) - z1.toMeters shouldBe (-3040909.4706983363 plusOrMinus 1e-7) + val (x1, y1, z1) = eraGd2Gce(6378137.0, 1.0 / 298.257223563, 3.1.rad, (-0.5).rad, 2500.0) + x1 shouldBe (-5599000.5577049947 plusOrMinus 1e-7) + y1 shouldBe (233011.67223479203 plusOrMinus 1e-7) + z1 shouldBe (-3040909.4706983363 plusOrMinus 1e-7) - val (x2, y2, z2) = eraGd2Gce(6378137.0.m, 1.0 / 298.257222101, 3.1.rad, (-0.5).rad, 2500.0.m) - x2.toMeters shouldBe (-5599000.5577260984 plusOrMinus 1e-7) - y2.toMeters shouldBe (233011.6722356702949 plusOrMinus 1e-7) - z2.toMeters shouldBe (-3040909.4706095476 plusOrMinus 1e-7) + val (x2, y2, z2) = eraGd2Gce(6378137.0, 1.0 / 298.257222101, 3.1.rad, (-0.5).rad, 2500.0) + x2 shouldBe (-5599000.5577260984 plusOrMinus 1e-7) + y2 shouldBe (233011.6722356702949 plusOrMinus 1e-7) + z2 shouldBe (-3040909.4706095476 plusOrMinus 1e-7) - val (x3, y3, z3) = eraGd2Gce(6378135.0.m, 1.0 / 298.26, 3.1.rad, (-0.5).rad, 2500.0.m) - x3.toMeters shouldBe (-5598998.7626301490 plusOrMinus 1e-7) - y3.toMeters shouldBe (233011.5975297822211 plusOrMinus 1e-7) - z3.toMeters shouldBe (-3040908.6861467111 plusOrMinus 1e-7) + val (x3, y3, z3) = eraGd2Gce(6378135.0, 1.0 / 298.26, 3.1.rad, (-0.5).rad, 2500.0) + x3 shouldBe (-5598998.7626301490 plusOrMinus 1e-7) + y3 shouldBe (233011.5975297822211 plusOrMinus 1e-7) + z3 shouldBe (-3040908.6861467111 plusOrMinus 1e-7) } "eraC2ixys" { val m = eraC2ixys(0.5791308486706011000e-3, 0.4020579816732961219e-4, (-0.1220040848472271978e-7).rad) @@ -149,35 +155,35 @@ class ErfaTest : StringSpec() { "eraApcs" { val astro = eraApcs( 2456384.5, 0.970031644, - (-1836024.09).m, 1056607.72.m, (-5998795.26).m, - -77.0361767, -133.310856, 0.0971855934, - (-0.974170438).au, (-0.211520082).au, (-0.0917583024).au, - 0.00364365824.auDay, (-0.0154287319).auDay, (-0.00668922024).auDay, - (-0.973458265).au, (-0.209215307).au, (-0.0906996477).au, + Vector3D(-1836024.09, 1056607.7, -5998795.26), + Vector3D(-77.0361767, -133.310856, 0.0971855934), + Vector3D(-0.974170438, -0.211520082, -0.0917583024), + Vector3D(0.00364365824, -0.0154287319, -0.00668922024), + Vector3D(-0.973458265, -0.209215307, -0.0906996477), ) astro.pmt shouldBe (13.25248468622587269 plusOrMinus 1e-11) astro.eb.x shouldBe (-0.9741827110629881886 plusOrMinus 1e-12) astro.eb.y shouldBe (-0.2115130190136415986 plusOrMinus 1e-12) astro.eb.z shouldBe (-0.09179840186954412099 plusOrMinus 1e-12) - astro.ehx shouldBe (-0.9736425571689454706 plusOrMinus 1e-12) - astro.ehy shouldBe (-0.2092452125850435930 plusOrMinus 1e-12) - astro.ehz shouldBe (-0.09075578152248299218 plusOrMinus 1e-12) + astro.eh.x shouldBe (-0.9736425571689454706 plusOrMinus 1e-12) + astro.eh.y shouldBe (-0.2092452125850435930 plusOrMinus 1e-12) + astro.eh.z shouldBe (-0.09075578152248299218 plusOrMinus 1e-12) astro.em shouldBe (0.9998233241709796859 plusOrMinus 1e-12) - astro.vx shouldBe (0.2078704993282685510e-4 plusOrMinus 1e-16) - astro.vy shouldBe (-0.8955360106989405683e-4 plusOrMinus 1e-16) - astro.vz shouldBe (-0.3863338994289409097e-4 plusOrMinus 1e-16) + astro.v.x shouldBe (0.2078704993282685510e-4 plusOrMinus 1e-16) + astro.v.y shouldBe (-0.8955360106989405683e-4 plusOrMinus 1e-16) + astro.v.z shouldBe (-0.3863338994289409097e-4 plusOrMinus 1e-16) astro.bm1 shouldBe (0.9999999950277561237 plusOrMinus 1e-12) } "eraApco" { val astro = eraApco( 2456384.5, 0.970031644, - (-0.974170438).au, (-0.211520082).au, (-0.0917583024).au, - 0.00364365824.auDay, (-0.0154287319).auDay, (-0.00668922024).auDay, - (-0.973458265).au, (-0.209215307).au, (-0.0906996477).au, + Vector3D(-0.974170438, -0.211520082, -0.0917583024), + Vector3D(0.00364365824, -0.0154287319, -0.00668922024), + Vector3D(-0.973458265, -0.209215307, -0.0906996477), 0.0013122272, -2.92808623e-5, 3.05749468e-8.rad, 3.14540971.rad, (-0.527800806).rad, (-1.2345856).rad, - 2738.0.m, + 2738.0, 2.47230737e-7.rad, 1.82640464e-6.rad, (-3.01974337e-11).rad, 0.000201418779.rad, (-2.36140831e-7).rad, ) @@ -186,13 +192,13 @@ class ErfaTest : StringSpec() { astro.eb.x shouldBe (-0.9741827110630322720 plusOrMinus 1e-12) astro.eb.y shouldBe (-0.2115130190135344832 plusOrMinus 1e-12) astro.eb.z shouldBe (-0.09179840186949532298 plusOrMinus 1e-12) - astro.ehx shouldBe (-0.9736425571689739035 plusOrMinus 1e-12) - astro.ehy shouldBe (-0.2092452125849330936 plusOrMinus 1e-12) - astro.ehz shouldBe (-0.09075578152243272599 plusOrMinus 1e-12) + astro.eh.x shouldBe (-0.9736425571689739035 plusOrMinus 1e-12) + astro.eh.y shouldBe (-0.2092452125849330936 plusOrMinus 1e-12) + astro.eh.z shouldBe (-0.09075578152243272599 plusOrMinus 1e-12) astro.em shouldBe (0.9998233241709957653 plusOrMinus 1e-12) - astro.vx shouldBe (0.2078704992916728762e-4 plusOrMinus 1e-16) - astro.vy shouldBe (-0.8955360107151952319e-4 plusOrMinus 1e-16) - astro.vz shouldBe (-0.3863338994288951082e-4 plusOrMinus 1e-16) + astro.v.x shouldBe (0.2078704992916728762e-4 plusOrMinus 1e-16) + astro.v.y shouldBe (-0.8955360107151952319e-4 plusOrMinus 1e-16) + astro.v.z shouldBe (-0.3863338994288951082e-4 plusOrMinus 1e-16) astro.bm1 shouldBe (0.9999999950277561236 plusOrMinus 1e-12) astro.bpn[0, 0] shouldBe (0.9999991390295159156 plusOrMinus 1e-12) astro.bpn[1, 0] shouldBe (0.4978650072505016932e-7 plusOrMinus 1e-12) @@ -282,22 +288,22 @@ class ErfaTest : StringSpec() { "eraApcg" { val astrom = eraApcg( 2456165.5, 0.401182685, - 0.901310875.au, (-0.417402664).au, (-0.180982288).au, - 0.00742727954.auDay, 0.0140507459.auDay, 0.00609045792.auDay, - 0.903358544.au, (-0.415395237).au, (-0.180084014).au + Vector3D(0.901310875, -0.417402664, -0.180982288), + Vector3D(0.00742727954, 0.0140507459, 0.00609045792), + Vector3D(0.903358544, -0.415395237, -0.180084014), ) astrom.pmt shouldBe (12.65133794027378508 plusOrMinus 1e-11) astrom.eb.x shouldBe (0.901310875 plusOrMinus 1e-12) astrom.eb.y shouldBe (-0.417402664 plusOrMinus 1e-12) astrom.eb.z shouldBe (-0.180982288 plusOrMinus 1e-12) - astrom.ehx shouldBe (0.8940025429324143045 plusOrMinus 1e-12) - astrom.ehy shouldBe (-0.4110930268679817955 plusOrMinus 1e-12) - astrom.ehz shouldBe (-0.1782189004872870264 plusOrMinus 1e-12) + astrom.eh.x shouldBe (0.8940025429324143045 plusOrMinus 1e-12) + astrom.eh.y shouldBe (-0.4110930268679817955 plusOrMinus 1e-12) + astrom.eh.z shouldBe (-0.1782189004872870264 plusOrMinus 1e-12) astrom.em shouldBe (1.010465295811013146 plusOrMinus 1e-12) - astrom.vx shouldBe (0.4289638913597693554e-4 plusOrMinus 1e-16) - astrom.vy shouldBe (0.8115034051581320575e-4 plusOrMinus 1e-16) - astrom.vz shouldBe (0.3517555136380563427e-4 plusOrMinus 1e-16) + astrom.v.x shouldBe (0.4289638913597693554e-4 plusOrMinus 1e-16) + astrom.v.y shouldBe (0.8115034051581320575e-4 plusOrMinus 1e-16) + astrom.v.z shouldBe (0.3517555136380563427e-4 plusOrMinus 1e-16) astrom.bm1 shouldBe (0.9999999951686012981 plusOrMinus 1e-12) } "eraEpv00" { @@ -325,13 +331,13 @@ class ErfaTest : StringSpec() { astrom.eb.x shouldBe (0.9013108747340644755 plusOrMinus 1e-12) astrom.eb.y shouldBe (-0.4174026640406119957 plusOrMinus 1e-12) astrom.eb.z shouldBe (-0.1809822877867817771 plusOrMinus 1e-12) - astrom.ehx shouldBe (0.8940025429255499549 plusOrMinus 1e-12) - astrom.ehy shouldBe (-0.4110930268331896318 plusOrMinus 1e-12) - astrom.ehz shouldBe (-0.1782189006019749850 plusOrMinus 1e-12) + astrom.eh.x shouldBe (0.8940025429255499549 plusOrMinus 1e-12) + astrom.eh.y shouldBe (-0.4110930268331896318 plusOrMinus 1e-12) + astrom.eh.z shouldBe (-0.1782189006019749850 plusOrMinus 1e-12) astrom.em shouldBe (1.010465295964664178 plusOrMinus 1e-12) - astrom.vx shouldBe (0.4289638912941341125e-4 plusOrMinus 1e-16) - astrom.vy shouldBe (0.8115034032405042132e-4 plusOrMinus 1e-16) - astrom.vz shouldBe (0.3517555135536470279e-4 plusOrMinus 1e-16) + astrom.v.x shouldBe (0.4289638912941341125e-4 plusOrMinus 1e-16) + astrom.v.y shouldBe (0.8115034032405042132e-4 plusOrMinus 1e-16) + astrom.v.z shouldBe (0.3517555135536470279e-4 plusOrMinus 1e-16) astrom.bm1 shouldBe (0.9999999951686013142 plusOrMinus 1e-12) } "eraAe2hd" { @@ -365,7 +371,7 @@ class ErfaTest : StringSpec() { dt shouldBe (-0.1280368005936998991e-2 plusOrMinus 1E-15) } "eraPvtob" { - val (p, v) = eraPvtob(2.0.rad, 0.5.rad, 3000.0.m, 1e-6.rad, (-0.5e-6).rad, 1e-8.rad, 5.0.rad) + val (p, v) = eraPvtob(2.0.rad, 0.5.rad, 3000.0, 1e-6.rad, (-0.5e-6).rad, 1e-8.rad, 5.0.rad) p[0] shouldBe (4225081.367071159207 plusOrMinus 1e-5) p[1] shouldBe (3681943.215856198144 plusOrMinus 1e-5) p[2] shouldBe (3041149.399241260785 plusOrMinus 1e-5) @@ -776,7 +782,7 @@ class ErfaTest : StringSpec() { rbp[2, 2] shouldBe (0.9999999285680153377 plusOrMinus 1e-12) } "eraPn00" { - val (epsa, rb, rp, rbp, rn, rbpn) = eraPn00(2400000.5, 53736.0, -0.9632552291149335877e-5, 0.4063197106621141414e-4) + val (_, _, epsa, rb, rp, rbp, rn, rbpn) = eraPn00(2400000.5, 53736.0, -0.9632552291149335877e-5, 0.4063197106621141414e-4) epsa shouldBe (0.4090791789404229916 plusOrMinus 1e-12) @@ -856,5 +862,232 @@ class ErfaTest : StringSpec() { rc2t[2, 1] shouldBe (0.3977631855605078674 plusOrMinus 1e-12) rc2t[2, 2] shouldBe (0.9174875068792735362 plusOrMinus 1e-12) } + "eraS00" { + val s = eraS00(2400000.5, 53736.0, 0.5791308486706011000e-3, 0.4020579816732961219e-4) + s shouldBe (-0.1220036263270905693e-7 plusOrMinus 1e-18) + } + "eraS00b" { + val s = eraS00b(2400000.5, 52541.0) + s shouldBe (-0.1340695782951026584e-7 plusOrMinus 1e-18) + } + "eraS00a" { + val s = eraS00a(2400000.5, 52541.0) + s shouldBe (-0.1340684448919163584e-7 plusOrMinus 1e-18) + } + "eraApco13" { + val (astrom, eo) = eraApco13( + 2456384.5, 0.969254051, 0.1550675, + -0.527800806, -1.2345856, 2738.0, + 2.47230737e-7, 1.82640464e-6, + 731.0, 12.8, 0.59, 0.55 + ) + + astrom.pmt shouldBe (13.25248468622475727 plusOrMinus 1e-11) + astrom.eb.x shouldBe (-0.9741827107320875162 plusOrMinus 1e-12) + astrom.eb.y shouldBe (-0.2115130190489716682 plusOrMinus 1e-12) + astrom.eb.z shouldBe (-0.09179840189496755339 plusOrMinus 1e-12) + astrom.eh.x shouldBe (-0.9736425572586935247 plusOrMinus 1e-12) + astrom.eh.y shouldBe (-0.2092452121603336166 plusOrMinus 1e-12) + astrom.eh.z shouldBe (-0.09075578153885665295 plusOrMinus 1e-12) + astrom.em shouldBe (0.9998233240913898141 plusOrMinus 1e-12) + astrom.v.x shouldBe (0.2078704994520489246e-4 plusOrMinus 1e-16) + astrom.v.y shouldBe (-0.8955360133238868938e-4 plusOrMinus 1e-16) + astrom.v.z shouldBe (-0.3863338993055887398e-4 plusOrMinus 1e-16) + astrom.bm1 shouldBe (0.9999999950277561004 plusOrMinus 1e-12) + astrom.bpn[0, 0] shouldBe (0.9999991390295147999 plusOrMinus 1e-12) + astrom.bpn[1, 0] shouldBe (0.4978650075315529277e-7 plusOrMinus 1e-12) + astrom.bpn[2, 0] shouldBe (0.001312227200850293372 plusOrMinus 1e-12) + astrom.bpn[0, 1] shouldBe (-0.1136336652812486604e-7 plusOrMinus 1e-12) + astrom.bpn[1, 1] shouldBe (0.9999999995713154865 plusOrMinus 1e-12) + astrom.bpn[2, 1] shouldBe (-0.2928086230975367296e-4 plusOrMinus 1e-12) + astrom.bpn[0, 2] shouldBe (-0.001312227201745553566 plusOrMinus 1e-12) + astrom.bpn[1, 2] shouldBe (0.2928082218847679162e-4 plusOrMinus 1e-12) + astrom.bpn[2, 2] shouldBe (0.9999991386008312212 plusOrMinus 1e-12) + astrom.along shouldBe (-0.5278008060295995733 plusOrMinus 1e-12) + astrom.xpl shouldBe (0.1133427418130752958e-5 plusOrMinus 1e-17) + astrom.ypl shouldBe (0.1453347595780646207e-5 plusOrMinus 1e-17) + astrom.sphi shouldBe (-0.9440115679003211329 plusOrMinus 1e-12) + astrom.cphi shouldBe (0.3299123514971474711 plusOrMinus 1e-12) + astrom.diurab shouldBeExactly 0.0 + astrom.eral shouldBe (2.617608909189664000 plusOrMinus 1e-12) + astrom.refa shouldBe (0.2014187785940396921e-3 plusOrMinus 1e-15) + astrom.refb shouldBe (-0.2361408314943696227e-6 plusOrMinus 1e-18) + eo shouldBe (-0.003020548354802412839 plusOrMinus 1e-14) + } + "eraPmpx" { + val pco = eraPmpx(1.234, 0.789, 1e-5, -2e-5, 1e-2, 10.0, 8.75, Vector3D(0.9, 0.4, 0.1)) + pco[0] shouldBe (0.2328137623960308438 plusOrMinus 1e-12) + pco[1] shouldBe (0.6651097085397855328 plusOrMinus 1e-12) + pco[2] shouldBe (0.7095257765896359837 plusOrMinus 1e-12) + } + "eraAtciq" { + val (astrom) = eraApci13(2456165.5, 0.401182685) + val (ri, di) = eraAtciq(2.71, 0.174, 1e-5, 5e-6, 0.1, 55.0, astrom) + ri shouldBe (2.710121572968696744 plusOrMinus 1e-12) + di shouldBe (0.1729371367219539137 plusOrMinus 1e-12) + } + "eraAtciqz" { + val (astrom) = eraApci13(2456165.5, 0.401182685) + val (ri, di) = eraAtciqz(2.71, 0.174, astrom) + ri shouldBe (2.709994899247256984 plusOrMinus 1e-12) + di shouldBe (0.1728740720984931891 plusOrMinus 1e-12) + } + "eraAtci13" { + val (ri, di, eo) = eraAtci13(2.71, 0.174, 1e-5, 5e-6, 0.1, 55.0, 2456165.5, 0.401182685) + ri shouldBe (2.710121572968696744 plusOrMinus 1e-12) + di shouldBe (0.1729371367219539137 plusOrMinus 1e-12) + eo shouldBe (-0.002900618712657375647 plusOrMinus 1e-14) + } + "eraApci13" { + val (astrom, eo) = eraApci13(2456165.5, 0.401182685) + + astrom.pmt shouldBe (12.65133794027378508 plusOrMinus 1e-11) + astrom.eb[0] shouldBe (0.9013108747340644755 plusOrMinus 1e-12) + astrom.eb[1] shouldBe (-0.4174026640406119957 plusOrMinus 1e-12) + astrom.eb[2] shouldBe (-0.1809822877867817771 plusOrMinus 1e-12) + astrom.eh[0] shouldBe (0.8940025429255499549 plusOrMinus 1e-12) + astrom.eh[1] shouldBe (-0.4110930268331896318 plusOrMinus 1e-12) + astrom.eh[2] shouldBe (-0.1782189006019749850 plusOrMinus 1e-12) + astrom.em shouldBe (1.010465295964664178 plusOrMinus 1e-12) + astrom.v[0] shouldBe (0.4289638912941341125e-4 plusOrMinus 1e-16) + astrom.v[1] shouldBe (0.8115034032405042132e-4 plusOrMinus 1e-16) + astrom.v[2] shouldBe (0.3517555135536470279e-4 plusOrMinus 1e-16) + astrom.bm1 shouldBe (0.9999999951686013142 plusOrMinus 1e-12) + astrom.bpn[0, 0] shouldBe (0.9999992060376761710 plusOrMinus 1e-12) + astrom.bpn[1, 0] shouldBe (0.4124244860106037157e-7 plusOrMinus 1e-12) + astrom.bpn[2, 0] shouldBe (0.1260128571051709670e-2 plusOrMinus 1e-12) + astrom.bpn[0, 1] shouldBe (-0.1282291987222130690e-7 plusOrMinus 1e-12) + astrom.bpn[1, 1] shouldBe (0.9999999997456835325 plusOrMinus 1e-12) + astrom.bpn[2, 1] shouldBe (-0.2255288829420524935e-4 plusOrMinus 1e-12) + astrom.bpn[0, 2] shouldBe (-0.1260128571661374559e-2 plusOrMinus 1e-12) + astrom.bpn[1, 2] shouldBe (0.2255285422953395494e-4 plusOrMinus 1e-12) + astrom.bpn[2, 2] shouldBe (0.9999992057833604343 plusOrMinus 1e-12) + eo shouldBe (-0.2900618712657375647e-2 plusOrMinus 1e-12) + } + "eraLdsun" { + val p = Vector3D(-0.763276255, -0.608633767, -0.216735543) + val e = Vector3D(-0.973644023, -0.20925523, -0.0907169552) + val p1 = eraLdsun(p, e, 0.999809214) + p1[0] shouldBe (-0.7632762580731413169 plusOrMinus 1e-12) + p1[1] shouldBe (-0.6086337635262647900 plusOrMinus 1e-12) + p1[2] shouldBe (-0.2167355419322321302 plusOrMinus 1e-12) + } + "eraLd" { + val p = Vector3D(-0.763276255, -0.608633767, -0.216735543) + val q = Vector3D(-0.763276255, -0.608633767, -0.216735543) + val e = Vector3D(0.76700421, 0.605629598, 0.211937094) + val p1 = eraLd(0.00028574, p, q, e, 8.91276983, 3e-10) + p1[0] shouldBe (-0.7632762548968159627 plusOrMinus 1e-12) + p1[1] shouldBe (-0.6086337670823762701 plusOrMinus 1e-12) + p1[2] shouldBe (-0.2167355431320546947 plusOrMinus 1e-12) + } + "eraApci" { + val ebp = Vector3D(0.901310875, -0.417402664, -0.180982288) + val ebv = Vector3D(0.00742727954, 0.0140507459, 0.00609045792) + val ehp = Vector3D(0.903358544, -0.415395237, -0.180084014) + val astrom = eraApci(2456165.5, 0.401182685, ebp, ebv, ehp, 0.0013122272, -2.92808623e-5, 3.05749468e-8) + + astrom.pmt shouldBe (12.65133794027378508 plusOrMinus 1e-11) + astrom.eb[0] shouldBe (0.901310875 plusOrMinus 1e-12) + astrom.eb[1] shouldBe (-0.417402664 plusOrMinus 1e-12) + astrom.eb[2] shouldBe (-0.180982288 plusOrMinus 1e-12) + astrom.eh[0] shouldBe (0.8940025429324143045 plusOrMinus 1e-12) + astrom.eh[1] shouldBe (-0.4110930268679817955 plusOrMinus 1e-12) + astrom.eh[2] shouldBe (-0.1782189004872870264 plusOrMinus 1e-12) + astrom.em shouldBe (1.010465295811013146 plusOrMinus 1e-12) + astrom.v[0] shouldBe (0.4289638913597693554e-4 plusOrMinus 1e-16) + astrom.v[1] shouldBe (0.8115034051581320575e-4 plusOrMinus 1e-16) + astrom.v[2] shouldBe (0.3517555136380563427e-4 plusOrMinus 1e-16) + astrom.bm1 shouldBe (0.9999999951686012981 plusOrMinus 1e-12) + astrom.bpn[0, 0] shouldBe (0.9999991390295159156 plusOrMinus 1e-12) + astrom.bpn[1, 0] shouldBe (0.4978650072505016932e-7 plusOrMinus 1e-12) + astrom.bpn[2, 0] shouldBe (0.1312227200000000000e-2 plusOrMinus 1e-12) + astrom.bpn[0, 1] shouldBe (-0.1136336653771609630e-7 plusOrMinus 1e-12) + astrom.bpn[1, 1] shouldBe (0.9999999995713154868 plusOrMinus 1e-12) + astrom.bpn[2, 1] shouldBe (-0.2928086230000000000e-4 plusOrMinus 1e-12) + astrom.bpn[0, 2] shouldBe (-0.1312227200895260194e-2 plusOrMinus 1e-12) + astrom.bpn[1, 2] shouldBe (0.2928082217872315680e-4 plusOrMinus 1e-12) + astrom.bpn[2, 2] shouldBe (0.9999991386008323373 plusOrMinus 1e-12) + } + "eraApcs13" { + val p = Vector3D(-6241497.16, 401346.896, -1251136.04) + val v = Vector3D(-29.264597, -455.021831, 0.0266151194) + val astrom = eraApcs13(2456165.5, 0.401182685, p, v) + + astrom.pmt shouldBe (12.65133794027378508 plusOrMinus 1e-11) + astrom.eb[0] shouldBe (0.9012691529025250644 plusOrMinus 1e-12) + astrom.eb[1] shouldBe (-0.4173999812023194317 plusOrMinus 1e-12) + astrom.eb[2] shouldBe (-0.1809906511146429670 plusOrMinus 1e-12) + astrom.eh[0] shouldBe (0.8939939101760130792 plusOrMinus 1e-12) + astrom.eh[1] shouldBe (-0.4111053891734021478 plusOrMinus 1e-12) + astrom.eh[2] shouldBe (-0.1782336880636997374 plusOrMinus 1e-12) + astrom.em shouldBe (1.010428384373491095 plusOrMinus 1e-12) + astrom.v[0] shouldBe (0.4279877294121697570e-4 plusOrMinus 1e-16) + astrom.v[1] shouldBe (0.7963255087052120678e-4 plusOrMinus 1e-16) + astrom.v[2] shouldBe (0.3517564013384691531e-4 plusOrMinus 1e-16) + astrom.bm1 shouldBe (0.9999999952947980978 plusOrMinus 1e-12) + astrom.bpn shouldBe Matrix3D.IDENTITY + } + "eraAtioq" { + val astrom = + eraApio13(2456384.5, 0.969254051, 0.1550675, -0.527800806, -1.2345856, 2738.0, 2.47230737e-7, 1.82640464e-6, 731.0, 12.8, 0.59, 0.55) + val (aob, zob, hob, dob, rob) = eraAtioq(2.710121572969038991, 0.1729371367218230438, astrom) + + aob shouldBe (0.9233952224895122499e-1 plusOrMinus 1e-12) + zob shouldBe (1.407758704513549991 plusOrMinus 1e-12) + hob shouldBe (-0.9247619879881698140e-1 plusOrMinus 1e-12) + dob shouldBe (0.1717653435756234676 plusOrMinus 1e-12) + rob shouldBe (2.710085107988480746 plusOrMinus 1e-12) + } + "eraApio13" { + val astrom = + eraApio13(2456384.5, 0.969254051, 0.1550675, -0.527800806, -1.2345856, 2738.0, 2.47230737e-7, 1.82640464e-6, 731.0, 12.8, 0.59, 0.55) + + astrom.along shouldBe (-0.5278008060295995733 plusOrMinus 1e-12) + astrom.xpl shouldBe (0.1133427418130752958e-5 plusOrMinus 1e-17) + astrom.ypl shouldBe (0.1453347595780646207e-5 plusOrMinus 1e-17) + astrom.sphi shouldBe (-0.9440115679003211329 plusOrMinus 1e-12) + astrom.cphi shouldBe (0.3299123514971474711 plusOrMinus 1e-12) + astrom.diurab shouldBe (0.5135843661699913529e-6 plusOrMinus 1e-12) + astrom.eral shouldBe (2.617608909189664000 plusOrMinus 1e-12) + astrom.refa shouldBe (0.2014187785940396921e-3 plusOrMinus 1e-15) + astrom.refb shouldBe (-0.2361408314943696227e-6 plusOrMinus 1e-18) + } + "eraApio" { + val astrom = + eraApio(-3.01974337e-11, 3.14540971, -0.527800806, -1.2345856, 2738.0, 2.47230737e-7, 1.82640464e-6, 0.000201418779, -2.36140831e-7) + + astrom.along shouldBe (-0.5278008060295995734 plusOrMinus 1e-12) + astrom.xpl shouldBe (0.1133427418130752958e-5 plusOrMinus 1e-17) + astrom.ypl shouldBe (0.1453347595780646207e-5 plusOrMinus 1e-17) + astrom.sphi shouldBe (-0.9440115679003211329 plusOrMinus 1e-12) + astrom.cphi shouldBe (0.3299123514971474711 plusOrMinus 1e-12) + astrom.diurab shouldBe (0.5135843661699913529e-6 plusOrMinus 1e-12) + astrom.eral shouldBe (2.617608903970400427 plusOrMinus 1e-12) + astrom.refa shouldBe (0.2014187790000000000e-3 plusOrMinus 1e-15) + astrom.refb shouldBe (-0.2361408310000000000e-6 plusOrMinus 1e-18) + } + "eraAtco13" { + val (b, eo) = eraAtco13( + 2.71, 0.174, 1e-5, 5e-6, 0.1, 55.0, 2456384.5, 0.969254051, + 0.1550675, -0.527800806, -1.2345856, 2738.0, 2.47230737e-7, 1.82640464e-6, 731.0, 12.8, + 0.59, 0.55 + ) + + val (aob, zob, hob, dob, rob) = b + + aob shouldBe (0.9251774485485515207e-1 plusOrMinus 1e-12) + zob shouldBe (1.407661405256499357 plusOrMinus 1e-12) + hob shouldBe (-0.9265154431529724692e-1 plusOrMinus 1e-12) + dob shouldBe (0.1716626560072526200 plusOrMinus 1e-12) + rob shouldBe (2.710260453504961012 plusOrMinus 1e-12) + eo shouldBe (-0.003020548354802412839 plusOrMinus 1e-14) + } + "eraAticq" { + val (astrom) = eraApci13(2456165.5, 0.401182685) + val (ri, di) = eraAticq(2.710121572969038991, 0.1729371367218230438, astrom) + ri shouldBe (2.710126504531716819 plusOrMinus 1e-12) + di shouldBe (0.1740632537627034482 plusOrMinus 1e-12) + } } } diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/Header.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/Header.kt index b3279bbeb..f35e07969 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/Header.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/Header.kt @@ -18,7 +18,7 @@ open class Header internal constructor(@JvmField internal val cards: LinkedList< constructor(header: Header) : this(LinkedList(header.cards)) - fun readOnly(): Header = ReadOnlyHeader(this) + open fun readOnly(): Header = ReadOnlyHeader(this) override fun clear() { cards.clear() diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/ReadOnlyHeader.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/ReadOnlyHeader.kt index 59476e5cd..522b2e17e 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/ReadOnlyHeader.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/ReadOnlyHeader.kt @@ -3,7 +3,7 @@ package nebulosa.fits import nebulosa.io.SeekableSource import java.util.* -internal class ReadOnlyHeader : Header { +open class ReadOnlyHeader : Header { constructor() : super(LinkedList()) @@ -26,4 +26,6 @@ internal class ReadOnlyHeader : Header { override fun add(card: HeaderCard) = throw UnsupportedOperationException("Header is read-only") override fun delete(key: FitsHeader) = throw UnsupportedOperationException("Header is read-only") + + override fun readOnly() = this } diff --git a/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/GuidePoint.kt b/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/GuidePoint.kt index ec6b5d870..54a4e921d 100644 --- a/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/GuidePoint.kt +++ b/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/GuidePoint.kt @@ -1,25 +1,30 @@ package nebulosa.guiding.internal import nebulosa.math.Angle +import nebulosa.math.Point2D +import nebulosa.math.rad +import kotlin.math.atan2 import kotlin.math.hypot -interface GuidePoint { - - val x: Double - - val y: Double +interface GuidePoint : Point2D { val valid: Boolean - fun dX(point: GuidePoint): Double - - fun dY(point: GuidePoint): Double + fun dX(point: Point2D): Double { + return x - point.x + } - val distance: Double + fun dY(point: Point2D): Double { + return y - point.y + } - fun distance(point: GuidePoint) = hypot(dX(point), dY(point)) + val distance + get() = hypot(x, y) - val angle: Angle + val angle + get() = atan2(y, x).rad - fun angle(point: GuidePoint): Angle + fun angle(point: Point2D): Angle { + return atan2(dY(point), dX(point)).rad + } } diff --git a/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/Point.kt b/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/Point.kt index 546e5750a..c1625505a 100644 --- a/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/Point.kt +++ b/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/Point.kt @@ -1,9 +1,6 @@ package nebulosa.guiding.internal -import nebulosa.math.Angle -import nebulosa.math.rad -import kotlin.math.atan2 -import kotlin.math.hypot +import nebulosa.math.Point2D /** * Represents a location on a guide camera image. @@ -32,34 +29,10 @@ open class Point( valid = true } - internal inline fun set(point: GuidePoint) { + internal inline fun set(point: Point2D) { set(point.x, point.y) } - override fun dX(point: GuidePoint): Double { - return x - point.x - } - - override fun dY(point: GuidePoint): Double { - return y - point.y - } - - override val distance - get() = hypot(x, y) - - override fun distance(point: GuidePoint) = hypot(dX(point), dY(point)) - - override val angle - get() = if (x != 0.0 || y != 0.0) atan2(y, x).rad - else 0.0 - - override fun angle(point: GuidePoint): Angle { - val dx = dX(point) - val dy = dY(point) - return if (dx != 0.0 || dy != 0.0) atan2(dy, dx).rad - else 0.0 - } - internal open fun invalidate() { valid = false } diff --git a/nebulosa-guiding-phd2/src/main/kotlin/nebulosa/guiding/phd2/PHD2Guider.kt b/nebulosa-guiding-phd2/src/main/kotlin/nebulosa/guiding/phd2/PHD2Guider.kt index 95ba77531..2cb26cef0 100644 --- a/nebulosa-guiding-phd2/src/main/kotlin/nebulosa/guiding/phd2/PHD2Guider.kt +++ b/nebulosa-guiding-phd2/src/main/kotlin/nebulosa/guiding/phd2/PHD2Guider.kt @@ -1,7 +1,7 @@ package nebulosa.guiding.phd2 -import nebulosa.common.concurrency.CancellationToken -import nebulosa.common.concurrency.CountUpDownLatch +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.common.concurrency.latch.CountUpDownLatch import nebulosa.guiding.* import nebulosa.log.loggerFor import nebulosa.math.arcsec diff --git a/nebulosa-guiding/src/main/kotlin/nebulosa/guiding/Guider.kt b/nebulosa-guiding/src/main/kotlin/nebulosa/guiding/Guider.kt index 6d8ccc073..8fe3c8eaf 100644 --- a/nebulosa-guiding/src/main/kotlin/nebulosa/guiding/Guider.kt +++ b/nebulosa-guiding/src/main/kotlin/nebulosa/guiding/Guider.kt @@ -1,6 +1,6 @@ package nebulosa.guiding -import nebulosa.common.concurrency.CancellationToken +import nebulosa.common.concurrency.cancel.CancellationToken import java.io.Closeable import java.time.Duration diff --git a/nebulosa-math/src/main/kotlin/nebulosa/math/Matrix3D.kt b/nebulosa-math/src/main/kotlin/nebulosa/math/Matrix3D.kt index 909f93f42..0304ba8cc 100644 --- a/nebulosa-math/src/main/kotlin/nebulosa/math/Matrix3D.kt +++ b/nebulosa-math/src/main/kotlin/nebulosa/math/Matrix3D.kt @@ -1,7 +1,7 @@ package nebulosa.math @Suppress("NOTHING_TO_INLINE") -open class Matrix3D(@PublishedApi @JvmField internal val matrix: DoubleArray) { +open class Matrix3D(@PublishedApi @JvmField internal val matrix: DoubleArray) : Cloneable { constructor( a11: Double = 0.0, a12: Double = 0.0, a13: Double = 0.0, @@ -37,7 +37,7 @@ open class Matrix3D(@PublishedApi @JvmField internal val matrix: DoubleArray) { get() = matrix[8] // TODO: Potentially less stable than using LU decomposition - inline val determinant: Double + val determinant: Double get() { val a = a11 * (a22 * a33 - a23 * a32) val b = a12 * (a21 * a33 - a23 * a31) @@ -107,9 +107,9 @@ open class Matrix3D(@PublishedApi @JvmField internal val matrix: DoubleArray) { ) inline operator fun times(other: Vector3D) = Vector3D( - a11 * other.x + a12 * other.y + a13 * other.z, - a21 * other.x + a22 * other.y + a23 * other.z, - a31 * other.x + a32 * other.y + a33 * other.z, + a11 * other.vector[0] + a12 * other.vector[1] + a13 * other.vector[2], + a21 * other.vector[0] + a22 * other.vector[1] + a23 * other.vector[2], + a31 * other.vector[0] + a32 * other.vector[1] + a33 * other.vector[2], ) inline operator fun times(scalar: Double) = Matrix3D( @@ -166,9 +166,11 @@ open class Matrix3D(@PublishedApi @JvmField internal val matrix: DoubleArray) { inline fun flipY() = Matrix3D(a13, a12, a11, a23, a22, a21, a33, a32, a31) + override fun clone() = Matrix3D(matrix.copyOf()) + fun isEmpty() = a11 == 0.0 && a12 == 0.0 && a13 == 0.0 && - a21 == 0.0 && a22 == 0.0 && a23 == 0.0 && - a31 == 0.0 && a32 == 0.0 && a33 == 0.0 + a21 == 0.0 && a22 == 0.0 && a23 == 0.0 && + a31 == 0.0 && a32 == 0.0 && a33 == 0.0 override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/nebulosa-math/src/main/kotlin/nebulosa/math/Point2D.kt b/nebulosa-math/src/main/kotlin/nebulosa/math/Point2D.kt new file mode 100644 index 000000000..ea18cf534 --- /dev/null +++ b/nebulosa-math/src/main/kotlin/nebulosa/math/Point2D.kt @@ -0,0 +1,18 @@ +package nebulosa.math + +import kotlin.math.hypot + +interface Point2D { + + val x: Double + + val y: Double + + operator fun component1() = x + + operator fun component2() = y + + fun distance(other: Point2D): Double { + return hypot(x - other.x, y - other.y) + } +} diff --git a/nebulosa-math/src/main/kotlin/nebulosa/math/Point3D.kt b/nebulosa-math/src/main/kotlin/nebulosa/math/Point3D.kt new file mode 100644 index 000000000..996d2a2ee --- /dev/null +++ b/nebulosa-math/src/main/kotlin/nebulosa/math/Point3D.kt @@ -0,0 +1,17 @@ +package nebulosa.math + +import kotlin.math.sqrt + +interface Point3D : Point2D { + + val z: Double + + operator fun component3() = z + + fun distance(other: Point3D): Double { + val dx = x - other.x + val dy = y - other.y + val dz = z - other.z + return sqrt(dx * dx + dy * dy + dz * dz) + } +} diff --git a/nebulosa-math/src/main/kotlin/nebulosa/math/Pressure.kt b/nebulosa-math/src/main/kotlin/nebulosa/math/Pressure.kt index 445695c67..901314ddd 100644 --- a/nebulosa-math/src/main/kotlin/nebulosa/math/Pressure.kt +++ b/nebulosa-math/src/main/kotlin/nebulosa/math/Pressure.kt @@ -6,7 +6,7 @@ import kotlin.math.exp import kotlin.math.pow /** - * Represents a pressure value in millibars. + * Represents a pressure value in millibars/hPa. */ typealias Pressure = Double diff --git a/nebulosa-math/src/main/kotlin/nebulosa/math/Unsafe.kt b/nebulosa-math/src/main/kotlin/nebulosa/math/Unsafe.kt new file mode 100644 index 000000000..39cc3f3ef --- /dev/null +++ b/nebulosa-math/src/main/kotlin/nebulosa/math/Unsafe.kt @@ -0,0 +1,59 @@ +package nebulosa.math + +import kotlin.annotation.AnnotationTarget.* + +@RequiresOptIn(level = RequiresOptIn.Level.WARNING) +@Retention(AnnotationRetention.BINARY) +@Target(CLASS, ANNOTATION_CLASS, PROPERTY, FIELD, LOCAL_VARIABLE, VALUE_PARAMETER, CONSTRUCTOR, FUNCTION, PROPERTY_GETTER, PROPERTY_SETTER, TYPEALIAS) +@MustBeDocumented +annotation class Unsafe + +/** + * Allows manipulate Vector's array in unsafe mode. + */ +@Unsafe +inline fun Vector3D.unsafe(block: UnsafeVector3D.() -> T) = with(UnsafeVector3D(this), block) + +/** + * Allows manipulate Vector's array in unsafe mode. + */ +@Unsafe +inline fun Vector3D.unsafe(block: UnsafeVector3D.() -> Unit) = UnsafeVector3D(this).also(block).vector + +/** + * Allows manipulate Matrix's array in unsafe mode. + */ +@Unsafe +inline fun Matrix3D.unsafe(block: UnsafeMatrix3D.() -> T) = with(UnsafeMatrix3D(this), block) + +/** + * Allows manipulate Matrix's array in unsafe mode. + */ +@Unsafe +inline fun Matrix3D.unsafe(block: UnsafeMatrix3D.() -> Unit) = UnsafeMatrix3D(this).also(block).matrix + +@JvmInline +@Suppress("NOTHING_TO_INLINE") +value class UnsafeVector3D(@JvmField @PublishedApi internal val vector: Vector3D) { + + inline operator fun get(index: Int): Double { + return vector.vector[index] + } + + inline operator fun set(index: Int, value: Double) { + vector.vector[index] = value + } +} + +@JvmInline +@Suppress("NOTHING_TO_INLINE") +value class UnsafeMatrix3D(@JvmField @PublishedApi internal val matrix: Matrix3D) { + + inline operator fun get(index: Int): Double { + return matrix.matrix[index] + } + + inline operator fun set(index: Int, value: Double) { + matrix.matrix[index] = value + } +} diff --git a/nebulosa-math/src/main/kotlin/nebulosa/math/Vector3D.kt b/nebulosa-math/src/main/kotlin/nebulosa/math/Vector3D.kt index 9dc0d4a65..30ded1004 100644 --- a/nebulosa-math/src/main/kotlin/nebulosa/math/Vector3D.kt +++ b/nebulosa-math/src/main/kotlin/nebulosa/math/Vector3D.kt @@ -1,11 +1,9 @@ package nebulosa.math -import kotlin.math.asin -import kotlin.math.atan2 -import kotlin.math.sqrt +import kotlin.math.* @Suppress("NOTHING_TO_INLINE") -open class Vector3D protected constructor(@PublishedApi @JvmField internal val vector: DoubleArray) { +open class Vector3D protected constructor(@PublishedApi @JvmField internal val vector: DoubleArray) : Point3D, Cloneable { constructor(x: Double = 0.0, y: Double = 0.0, z: Double = 0.0) : this(doubleArrayOf(x, y, z)) @@ -13,36 +11,47 @@ open class Vector3D protected constructor(@PublishedApi @JvmField internal val v inline operator fun get(index: Int) = vector[index] - inline operator fun plus(other: Vector3D) = Vector3D(x + other.x, y + other.y, z + other.z) + inline operator fun plus(other: Vector3D) = Vector3D(vector[0] + other[0], vector[1] + other[1], vector[2] + other[2]) - inline operator fun minus(other: Vector3D) = Vector3D(x - other.x, y - other.y, z - other.z) + inline operator fun minus(other: Vector3D) = Vector3D(vector[0] - other[0], vector[1] - other[1], vector[2] - other[2]) - inline operator fun times(scalar: Double) = Vector3D(x * scalar, y * scalar, z * scalar) + inline operator fun times(scalar: Double) = Vector3D(vector[0] * scalar, vector[1] * scalar, vector[2] * scalar) - inline operator fun times(vector: Vector3D) = Vector3D(x * vector.x, y * vector.y, z * vector.z) + inline operator fun times(vector: Vector3D) = Vector3D(vector[0] * vector[0], vector[1] * vector[1], vector[2] * vector[2]) - inline operator fun div(scalar: Double) = Vector3D(x / scalar, y / scalar, z / scalar) + inline operator fun div(scalar: Double) = Vector3D(vector[0] / scalar, vector[1] / scalar, vector[2] / scalar) - inline operator fun unaryMinus() = Vector3D(-x, -y, -z) + inline operator fun unaryMinus() = Vector3D(-vector[0], -vector[1], -vector[2]) - inline val x + override val x get() = vector[0] - inline val y + override val y get() = vector[1] - inline val z + override val z get() = vector[2] - inline operator fun component1() = x + inline fun array() = vector.copyOf() - inline operator fun component2() = y - - inline operator fun component3() = z + /** + * Scalar product between this vector and [other]. + */ + inline fun dot(other: Vector3D) = dot(other.vector) - inline fun dot(other: Vector3D) = x * other.x + y * other.y + z * other.z + /** + * Scalar product between this vector and [other]. + */ + inline fun dot(other: DoubleArray) = vector[0] * other[0] + vector[1] * other[1] + vector[2] * other[2] - inline fun cross(other: Vector3D) = Vector3D(y * other.z - z * other.y, z * other.x - x * other.z, x * other.y - y * other.x) + /** + * Cross product between this vector and [other]. + */ + inline fun cross(other: Vector3D) = Vector3D( + vector[1] * other[2] - vector[2] * other[1], + vector[2] * other[0] - vector[0] * other[2], + vector[0] * other[1] - vector[1] * other[0] + ) inline val length get() = sqrt(dot(this)) @@ -51,20 +60,34 @@ open class Vector3D protected constructor(@PublishedApi @JvmField internal val v get() = length.let { if (it == 0.0) this else this / it } inline val latitude - get() = asin(z / length).rad + get() = asin(vector[2] / length).rad inline val longitude - get() = atan2(y, x).rad.normalized + get() = atan2(vector[1], vector[0]).rad.normalized - inline fun isEmpty() = x == 0.0 && y == 0.0 && z == 0.0 + inline fun isEmpty() = vector[0] == 0.0 && vector[1] == 0.0 && vector[2] == 0.0 /** * Computes the angle between this vector and [vector]. */ - fun angle(vector: Vector3D): Angle { - val a = this * vector.length - val b = vector * length - return (2.0 * atan2((a - b).length, (a + b).length)).rad + fun angle(coordinate: Vector3D): Angle { + // val a = this * vector.length + // val b = vector * length + // return (2.0 * atan2((a - b).length, (a + b).length)).rad + + val dot = dot(coordinate) + val v = dot / (length * coordinate.length) + return if (abs(v) > 1.0) if (v < 0.0) SEMICIRCLE else 0.0 + else acos(v).rad + } + + override fun clone() = Vector3D(vector.copyOf()) + + /** + * Rotates this vector given an [axis] and [angle] of rotation. + */ + fun rotate(axis: Vector3D, angle: Angle): Vector3D { + return rotateByRodrigues(this, axis, angle) } override fun equals(other: Any?): Boolean { @@ -76,9 +99,7 @@ open class Vector3D protected constructor(@PublishedApi @JvmField internal val v return true } - override fun hashCode(): Int { - return vector.contentHashCode() - } + override fun hashCode() = vector.contentHashCode() override fun toString() = "${javaClass.simpleName}(x=$x, y=$y, z=$z)" @@ -88,5 +109,30 @@ open class Vector3D protected constructor(@PublishedApi @JvmField internal val v @JvmStatic val X = Vector3D(x = 1.0) @JvmStatic val Y = Vector3D(y = 1.0) @JvmStatic val Z = Vector3D(z = 1.0) + + /** + * Efficient algorithm for rotating a vector in space, given an [axis] and [angle] of rotation. + * + * @param v A vector in R³. + * @param axis A vector describing an axis of rotation about which [v] rotates. + * @param angle The angle that [v] should rotate by. + * + * @see Wiki + */ + @JvmStatic + fun rotateByRodrigues(v: Vector3D, axis: Vector3D, angle: Angle): Vector3D { + val cosa = angle.cos + val k = axis.normalized + return v * cosa + k.cross(v) * angle.sin + k * k.dot(v) * (1.0 - cosa) + } + + /** + * Determines the plane that goes through the three points [a], [b] and [c] + * and its defining vector. + */ + @JvmStatic + fun plane(a: Vector3D, b: Vector3D, c: Vector3D): Vector3D { + return (b - a).cross(c - b).normalized + } } } diff --git a/nebulosa-math/src/test/kotlin/Vector3DTest.kt b/nebulosa-math/src/test/kotlin/Vector3DTest.kt index 5ed4a4569..326a0c7be 100644 --- a/nebulosa-math/src/test/kotlin/Vector3DTest.kt +++ b/nebulosa-math/src/test/kotlin/Vector3DTest.kt @@ -2,36 +2,28 @@ import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.booleans.shouldBeFalse import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.doubles.shouldBeExactly +import io.kotest.matchers.shouldBe +import nebulosa.constants.PI +import nebulosa.constants.PIOVERTWO import nebulosa.math.Vector3D +import nebulosa.math.deg +import nebulosa.math.toDegrees +import nebulosa.test.plusOrMinus class Vector3DTest : StringSpec() { init { "plus vector" { - val m = Vector3D(2.0, 3.0, 2.0) - val n = Vector3D(2.0, 3.0, 2.0) - val r = m + n - - r[0] shouldBeExactly 4.0 - r[1] shouldBeExactly 6.0 - r[2] shouldBeExactly 4.0 + Vector3D(2.0, 3.0, 2.0) + Vector3D(2.0, 3.0, 2.0) shouldBe Vector3D(4.0, 6.0, 4.0) } "minus vector" { - val m = Vector3D(2.0, 3.0, 2.0) - val n = Vector3D(1.0, 1.0, 5.0) - val r = m - n - - r[0] shouldBeExactly 1.0 - r[1] shouldBeExactly 2.0 - r[2] shouldBeExactly -3.0 + Vector3D(2.0, 3.0, 2.0) - Vector3D(1.0, 1.0, 5.0) shouldBe Vector3D(1.0, 2.0, -3.0) } "times scalar" { - val m = Vector3D(2.0, 3.0, 2.0) - val r = m * 5.0 - - r[0] shouldBeExactly 10.0 - r[1] shouldBeExactly 15.0 - r[2] shouldBeExactly 10.0 + Vector3D(2.0, 3.0, 2.0) * 5.0 shouldBe Vector3D(10.0, 15.0, 10.0) + } + "divide by scalar" { + Vector3D(2.0, 3.0, 2.0) / 2.0 shouldBe Vector3D(1.0, 1.5, 1.0) } "dot" { val m = Vector3D(2.0, 3.0, 2.0) @@ -40,13 +32,7 @@ class Vector3DTest : StringSpec() { m.dot(-v) shouldBeExactly -17.0 } "cross" { - val m = Vector3D(2.0, 3.0, 2.0) - val v = Vector3D(3.0, 2.0, 3.0) - val r = m.cross(v) - - r[0] shouldBeExactly 5.0 - r[1] shouldBeExactly 0.0 - r[2] shouldBeExactly -5.0 + Vector3D(2.0, 3.0, 2.0).cross(Vector3D(3.0, 2.0, 3.0)) shouldBe Vector3D(5.0, 0.0, -5.0) } "is empty" { Vector3D(2.0, 3.0, 2.0).isEmpty().shouldBeFalse() @@ -55,5 +41,40 @@ class Vector3DTest : StringSpec() { Vector3D(0.0, 0.0, 4.0).isEmpty().shouldBeFalse() Vector3D(0.0, 0.0, 0.0).isEmpty().shouldBeTrue() } + "right angle" { + Vector3D.X.angle(Vector3D.Y) shouldBeExactly PIOVERTWO + } + "opposite" { + Vector3D(1.0, 2.0, 3.0).angle(Vector3D(-1.0, -2.0, -3.0)) shouldBeExactly PI + } + "collinear" { + Vector3D(2.0, -3.0, 1.0).angle(Vector3D(4.0, -6.0, 2.0)) shouldBeExactly 0.0 + } + "general" { + Vector3D(3.0, 4.0, 5.0).angle(Vector3D(1.0, 2.0, 2.0)).toDegrees shouldBeExactly 8.130102354156005 + } + "rotate around x axis" { + Vector3D.X.rotate(Vector3D.X, 90.0.deg) shouldBe Vector3D.X + } + "rotate around y axis" { + Vector3D.Y.rotate(Vector3D.Y, 90.0.deg) shouldBe Vector3D.Y + } + "rotate around z axis" { + Vector3D.Z.rotate(Vector3D.Z, 90.0.deg) shouldBe Vector3D.Z + } + "rotate" { + val v = Vector3D(1.0, 2.0, 3.0) + v.rotate(Vector3D.X, PI / 4.0) shouldBe (Vector3D(1.0, -0.707107, 3.535534) plusOrMinus 1e-6) + v.rotate(Vector3D.Y, PI / 4.0) shouldBe (Vector3D(2.828427, 2.0, 1.414213) plusOrMinus 1e-6) + v.rotate(Vector3D.Z, PI / 4.0) shouldBe (Vector3D(-0.707107, 2.12132, 3.0) plusOrMinus 1e-6) + val axis = Vector3D(3.0, 4.0, 5.0) + v.rotate(axis, 29.6512852.deg) shouldBe (Vector3D(1.21325856, 1.73061994, 3.08754891) plusOrMinus 1e-8) + v.rotate(axis, 120.3053274.deg) shouldBe (Vector3D(2.08677229, 1.63198489, 2.64234871) plusOrMinus 1e-8) + v.rotate(axis, 230.6512852.deg) shouldBe (Vector3D(1.69633894, 2.56816842, 2.12766190) plusOrMinus 1e-8) + v.rotate(axis, 359.6139797.deg) shouldBe (Vector3D(0.99810712, 2.00381299, 2.99808533) plusOrMinus 1e-8) + } + "no rotation" { + Vector3D(1.0, 2.0, 3.0).rotate(Vector3D.Y, 0.0) shouldBe Vector3D(1.0, 2.0, 3.0) + } } } diff --git a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/PlanetograhicPosition.kt b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/PlanetograhicPosition.kt index 5cbe95c9f..e8bfc0c2f 100644 --- a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/PlanetograhicPosition.kt +++ b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/PlanetograhicPosition.kt @@ -42,9 +42,7 @@ data class PlanetograhicPosition( // from position, to support situations where we were not // given a latitude and longitude. If that is not feasible, // then at least cache the product of these first two matrices. - val m = Matrix3D.rotY((TAU / 4.0 - latitude).rad) - .rotateZ((TAU / 2.0 - longitude).rad) * - frame.rotationAt(time) + val m = Matrix3D.rotY((TAU / 4.0 - latitude).rad).rotateZ((TAU / 2.0 - longitude).rad) * frame.rotationAt(time) // Turn clockwise into counterclockwise. // Flip the sign of y so that azimuth reads north-east rather than the other direction. diff --git a/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/commands/SetLockPosition.kt b/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/commands/SetLockPosition.kt index df6b906f9..5118423d9 100644 --- a/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/commands/SetLockPosition.kt +++ b/nebulosa-phd2-client/src/main/kotlin/nebulosa/phd2/client/commands/SetLockPosition.kt @@ -1,11 +1,13 @@ package nebulosa.phd2.client.commands +import nebulosa.math.Point2D + /** * When [exact] is true, the lock position is moved to the exact given coordinates ([x], [y]). * When false, the current position is moved to the given coordinates and if * a guide star is in range, the lock position is set to the coordinates of the guide star. */ -data class SetLockPosition(val x: Double, val y: Double, val exact: Boolean = true) : PHD2Command { +data class SetLockPosition(override val x: Double, override val y: Double, val exact: Boolean = true) : PHD2Command, Point2D { override val methodName = "set_lock_position" diff --git a/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolution.kt b/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolution.kt index a262200ec..9c42cfff8 100644 --- a/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolution.kt +++ b/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolution.kt @@ -1,11 +1,12 @@ package nebulosa.plate.solving import nebulosa.fits.Header +import nebulosa.fits.HeaderCard +import nebulosa.fits.ReadOnlyHeader import nebulosa.fits.Standard import nebulosa.log.loggerFor import nebulosa.math.* import nebulosa.wcs.computeCdMatrix -import nebulosa.wcs.hasCd import kotlin.math.abs import kotlin.math.atan2 import kotlin.math.cos @@ -21,11 +22,10 @@ data class PlateSolution( val height: Angle = 0.0, val parity: Parity = Parity.NORMAL, val radius: Angle = hypot(width, height).rad / 2.0, -) : Header() { + private val header: Collection = emptyList(), +) : ReadOnlyHeader(header) { - override fun toString() = "PlateSolution(solved=$solved, orientation=$orientation, scale=$scale, " + - "rightAscension=$rightAscension, declination=$declination, width=$width, " + - "height=$height, parity=$parity, radius=$radius, header=${super.toString()})" + override fun readOnly() = this companion object { @@ -51,9 +51,7 @@ data class PlateSolution( crval1.format(AngleFormatter.HMS), crval2.format(AngleFormatter.SIGNED_DMS), ) - val solution = PlateSolution(true, crota2, cdelt2, crval1, crval2, abs(cdelt1 * width), abs(cdelt2 * height)) - header.iterator().forEach(solution::add) - return solution + return PlateSolution(true, crota2, cdelt2, crval1, crval2, abs(cdelt1 * width), abs(cdelt2 * height), header = header) } } } diff --git a/nebulosa-star-detection/build.gradle.kts b/nebulosa-star-detection/build.gradle.kts index 24ca9524d..133ae63ca 100644 --- a/nebulosa-star-detection/build.gradle.kts +++ b/nebulosa-star-detection/build.gradle.kts @@ -4,6 +4,7 @@ plugins { } dependencies { + api(project(":nebulosa-math")) implementation(project(":nebulosa-log")) testImplementation(project(":nebulosa-test")) } diff --git a/nebulosa-star-detection/src/main/kotlin/nebulosa/star/detection/ImageStar.kt b/nebulosa-star-detection/src/main/kotlin/nebulosa/star/detection/ImageStar.kt index 348c1720f..fb911bdca 100644 --- a/nebulosa-star-detection/src/main/kotlin/nebulosa/star/detection/ImageStar.kt +++ b/nebulosa-star-detection/src/main/kotlin/nebulosa/star/detection/ImageStar.kt @@ -1,20 +1,12 @@ package nebulosa.star.detection -import kotlin.math.hypot +import nebulosa.math.Point2D -interface ImageStar { - - val x: Double - - val y: Double +interface ImageStar : Point2D { val hfd: Double val snr: Double val flux: Double - - fun distance(star: ImageStar): Double { - return hypot(x - star.x, y - star.y) - } } diff --git a/nebulosa-test/src/main/kotlin/nebulosa/test/MathMatchers.kt b/nebulosa-test/src/main/kotlin/nebulosa/test/MathMatchers.kt new file mode 100644 index 000000000..f49e4ad40 --- /dev/null +++ b/nebulosa-test/src/main/kotlin/nebulosa/test/MathMatchers.kt @@ -0,0 +1,51 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package nebulosa.test + +import io.kotest.matchers.Matcher +import io.kotest.matchers.MatcherResult +import io.kotest.matchers.doubles.ToleranceMatcher +import nebulosa.math.Matrix3D +import nebulosa.math.Vector3D + +inline infix fun Vector3D.plusOrMinus(tolerance: Double) = Vector3DMatcher(this, tolerance) + +class Vector3DMatcher(expected: Vector3D, tolerance: Double) : Matcher { + + private val xMatcher = ToleranceMatcher(expected.x, tolerance) + private val yMatcher = ToleranceMatcher(expected.y, tolerance) + private val zMatcher = ToleranceMatcher(expected.z, tolerance) + + override fun test(value: Vector3D): MatcherResult { + return xMatcher.test(value.x).takeIf { !it.passed() } + ?: yMatcher.test(value.y).takeIf { !it.passed() } + ?: zMatcher.test(value.z) + } +} + +inline infix fun Matrix3D.plusOrMinus(tolerance: Double) = Matrix3DMatcher(this, tolerance) + +class Matrix3DMatcher(expected: Matrix3D, tolerance: Double) : Matcher { + + private val a11Matcher = ToleranceMatcher(expected.a11, tolerance) + private val a12Matcher = ToleranceMatcher(expected.a12, tolerance) + private val a13Matcher = ToleranceMatcher(expected.a13, tolerance) + private val a21Matcher = ToleranceMatcher(expected.a21, tolerance) + private val a22Matcher = ToleranceMatcher(expected.a22, tolerance) + private val a23Matcher = ToleranceMatcher(expected.a23, tolerance) + private val a31Matcher = ToleranceMatcher(expected.a31, tolerance) + private val a32Matcher = ToleranceMatcher(expected.a32, tolerance) + private val a33Matcher = ToleranceMatcher(expected.a33, tolerance) + + override fun test(value: Matrix3D): MatcherResult { + return a11Matcher.test(value.a11).takeIf { !it.passed() } + ?: a12Matcher.test(value.a12).takeIf { !it.passed() } + ?: a13Matcher.test(value.a13).takeIf { !it.passed() } + ?: a21Matcher.test(value.a21).takeIf { !it.passed() } + ?: a22Matcher.test(value.a22).takeIf { !it.passed() } + ?: a23Matcher.test(value.a23).takeIf { !it.passed() } + ?: a31Matcher.test(value.a31).takeIf { !it.passed() } + ?: a32Matcher.test(value.a32).takeIf { !it.passed() } + ?: a33Matcher.test(value.a33) + } +} diff --git a/nebulosa-time/build.gradle.kts b/nebulosa-time/build.gradle.kts index d6f4dca50..53c46ddfe 100644 --- a/nebulosa-time/build.gradle.kts +++ b/nebulosa-time/build.gradle.kts @@ -4,6 +4,7 @@ plugins { } dependencies { + api(project(":nebulosa-common")) api(project(":nebulosa-erfa")) testImplementation(project(":nebulosa-test")) } diff --git a/api/src/main/kotlin/nebulosa/api/atlas/CurrentTime.kt b/nebulosa-time/src/main/kotlin/nebulosa/time/CurrentTime.kt similarity index 76% rename from api/src/main/kotlin/nebulosa/api/atlas/CurrentTime.kt rename to nebulosa-time/src/main/kotlin/nebulosa/time/CurrentTime.kt index 7c5eb5455..076b9afa3 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/CurrentTime.kt +++ b/nebulosa-time/src/main/kotlin/nebulosa/time/CurrentTime.kt @@ -1,22 +1,25 @@ -package nebulosa.api.atlas +package nebulosa.time -import nebulosa.time.InstantOfTime -import nebulosa.time.TimeDelta -import nebulosa.time.UTC +import nebulosa.common.time.Stopwatch object CurrentTime : InstantOfTime() { - private const val MAX_INTERVAL = 1000L * 30 // 30s. + const val MAX_INTERVAL_KEY = "CURRENT_TIME.MAX_INTERVAL" - @Volatile private var lastTime = 0L + private val maxInterval = System.getProperty(MAX_INTERVAL_KEY, "30000").toLongOrNull() ?: 30000L + private val stopwatch = Stopwatch() - private var time = UTC.now() - @Synchronized get() { - val curTime = System.currentTimeMillis() + init { + stopwatch.start() + } - if (curTime - lastTime >= MAX_INTERVAL) { - lastTime = curTime - field = UTC.now() + private var time = UTC.now() + get() { + synchronized(stopwatch) { + if (stopwatch.elapsedMilliseconds >= maxInterval) { + stopwatch.reset() + field = UTC.now() + } } return field diff --git a/nebulosa-wcs/src/main/kotlin/nebulosa/wcs/PixelCoordinates.kt b/nebulosa-wcs/src/main/kotlin/nebulosa/wcs/PixelCoordinates.kt index 92280dab6..e2c06a689 100644 --- a/nebulosa-wcs/src/main/kotlin/nebulosa/wcs/PixelCoordinates.kt +++ b/nebulosa-wcs/src/main/kotlin/nebulosa/wcs/PixelCoordinates.kt @@ -1,5 +1,6 @@ package nebulosa.wcs import nebulosa.math.Angle +import nebulosa.math.Point2D -data class PixelCoordinates(val x: Double, val y: Double, val phi: Angle, val theta: Angle) +data class PixelCoordinates(override val x: Double, override val y: Double, val phi: Angle, val theta: Angle) : Point2D From 968e7b2870c9bc2dda3e5ec937851ade905da72f Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sun, 28 Jan 2024 14:24:46 -0300 Subject: [PATCH 02/87] [api]: Minor fixes --- .../kotlin/nebulosa/api/calibration/CalibrationFrameService.kt | 2 +- .../main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt index 2bf8047c2..4a30e311f 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt @@ -32,7 +32,7 @@ class CalibrationFrameService( return if (darkFrame != null || biasFrame != null || flatFrame != null) { var transformedImage = if (createNew) image.clone() else image - var calibrationImage = Image(transformedImage.width, transformedImage.height, Header(), transformedImage.mono) + var calibrationImage = Image(transformedImage.width, transformedImage.height, Header.EMPTY, transformedImage.mono) if (biasFrame != null) { calibrationImage = Fits(biasFrame.path!!).also(Fits::read).use(calibrationImage::load)!! diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt index ba1caa1ce..5dbfcf6c3 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt @@ -1,6 +1,6 @@ package nebulosa.batch.processing -import nebulosa.common.concurrency.CancellationListener +import nebulosa.common.concurrency.cancel.CancellationListener import nebulosa.common.concurrency.cancel.CancellationSource import nebulosa.log.debug import nebulosa.log.loggerFor From 16ba16302f646366829e4c6d90b184b5e3b02eb6 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Thu, 1 Feb 2024 00:10:55 -0300 Subject: [PATCH 03/87] [api]: Implement Three Point Polar Alignment --- api/build.gradle.kts | 1 + .../polar/PolarAlignmentController.kt | 15 ++++ .../alignment/polar/PolarAlignmentService.kt | 14 ++++ .../api/alignment/polar/darv/DARVJob.kt | 9 ++- .../alignment/polar/darv/DARVStartRequest.kt | 2 +- .../api/alignment/polar/tppa/TPPAExecutor.kt | 77 +++++++++++++++++++ .../api/alignment/polar/tppa/TPPAJob.kt | 43 +++++++++++ .../alignment/polar/tppa/TPPAStartRequest.kt | 21 +++++ .../api/alignment/polar/tppa/TPPAStep.kt | 75 ++++++++++++++++++ .../beans/configurations/BeanConfiguration.kt | 7 ++ .../api/cameras/CameraStartCaptureRequest.kt | 5 ++ .../kotlin/nebulosa/api/image/ImageService.kt | 9 ++- .../nebulosa/api/mounts/MountSlewStep.kt | 73 ++++++++++++++++++ .../nebulosa/api/solver/PlateSolverOptions.kt | 8 +- .../nebulosa/api/solver/PlateSolverService.kt | 31 ++++---- nebulosa-alignment/build.gradle.kts | 1 - .../alignment/polar/PolarAlignment.kt | 8 -- .../alignment/polar/point/three/Position.kt | 1 - .../point/three/ThreePointPolarAlignment.kt | 40 ++++++---- .../astap/plate/solving/AstapPlateSolver.kt | 2 +- .../solving/LocalAstrometryNetPlateSolver.kt | 2 +- .../solving/NovaAstrometryNetPlateSolver.kt | 2 +- .../concurrency/latch/CountUpDownLatch.kt | 14 +++- nebulosa-common/src/test/kotlin/PauserTest.kt | 30 ++++++++ 24 files changed, 435 insertions(+), 55 deletions(-) create mode 100644 api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt create mode 100644 api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt create mode 100644 api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStartRequest.kt create mode 100644 api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt create mode 100644 api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt delete mode 100644 nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/PolarAlignment.kt create mode 100644 nebulosa-common/src/test/kotlin/PauserTest.kt diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 2186e87d7..697d0e536 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -10,6 +10,7 @@ plugins { } dependencies { + implementation(project(":nebulosa-alignment")) implementation(project(":nebulosa-astap")) implementation(project(":nebulosa-astrometrynet")) implementation(project(":nebulosa-batch-processing")) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt index 8d5f5b676..3e6caad76 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt @@ -1,9 +1,11 @@ package nebulosa.api.alignment.polar import nebulosa.api.alignment.polar.darv.DARVStartRequest +import nebulosa.api.alignment.polar.tppa.TPPAStartRequest import nebulosa.api.beans.converters.indi.DeviceOrEntityParam import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.guide.GuideOutput +import nebulosa.indi.device.mount.Mount import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -27,4 +29,17 @@ class PolarAlignmentController( fun darvStop(@DeviceOrEntityParam camera: Camera, @DeviceOrEntityParam guideOutput: GuideOutput) { polarAlignmentService.darvStop(camera, guideOutput) } + + @PutMapping("tppa/{camera}/{mount}/start") + fun tppaStart( + @DeviceOrEntityParam camera: Camera, @DeviceOrEntityParam mount: Mount, + @RequestBody body: TPPAStartRequest, + ) { + polarAlignmentService.tppaStart(camera, mount, body) + } + + @PutMapping("tppa/{camera}/{mount}/stop") + fun tppaStop(@DeviceOrEntityParam camera: Camera, @DeviceOrEntityParam mount: Mount) { + polarAlignmentService.tppaStop(camera, mount) + } } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt index 44e703b19..118f63c71 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt @@ -2,13 +2,17 @@ package nebulosa.api.alignment.polar import nebulosa.api.alignment.polar.darv.DARVExecutor import nebulosa.api.alignment.polar.darv.DARVStartRequest +import nebulosa.api.alignment.polar.tppa.TPPAExecutor +import nebulosa.api.alignment.polar.tppa.TPPAStartRequest import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.guide.GuideOutput +import nebulosa.indi.device.mount.Mount import org.springframework.stereotype.Service @Service class PolarAlignmentService( private val darvExecutor: DARVExecutor, + private val tppaExecutor: TPPAExecutor, ) { fun darvStart(camera: Camera, guideOutput: GuideOutput, darvStartRequest: DARVStartRequest) { @@ -20,4 +24,14 @@ class PolarAlignmentService( fun darvStop(camera: Camera, guideOutput: GuideOutput) { darvExecutor.stop(camera, guideOutput) } + + fun tppaStart(camera: Camera, mount: Mount, tppaStartRequest: TPPAStartRequest) { + check(camera.connected) { "camera not connected" } + check(mount.connected) { "mount not connected" } + tppaExecutor.execute(tppaStartRequest.copy(camera = camera, mount = mount)) + } + + fun tppaStop(camera: Camera, mount: Mount) { + tppaExecutor.stop(camera, mount) + } } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt index 0361a5a9d..00a06f0a7 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt @@ -1,7 +1,10 @@ package nebulosa.api.alignment.polar.darv import io.reactivex.rxjava3.subjects.PublishSubject -import nebulosa.api.cameras.* +import nebulosa.api.cameras.AutoSubFolderMode +import nebulosa.api.cameras.CameraCaptureListener +import nebulosa.api.cameras.CameraExposureFinished +import nebulosa.api.cameras.CameraExposureStep import nebulosa.api.guiding.GuidePulseListener import nebulosa.api.guiding.GuidePulseRequest import nebulosa.api.guiding.GuidePulseStep @@ -16,14 +19,14 @@ import java.nio.file.Files import java.time.Duration data class DARVJob( - val request: DARVStartRequest, + @JvmField val request: DARVStartRequest, ) : SimpleJob(), PublishSubscribe, CameraCaptureListener, GuidePulseListener, DelayStepListener { @JvmField val camera = requireNotNull(request.camera) @JvmField val guideOutput = requireNotNull(request.guideOutput) @JvmField val direction = if (request.reversed) request.direction.reversed else request.direction - @JvmField val cameraRequest = (request.capture ?: CameraStartCaptureRequest()).copy( + @JvmField val cameraRequest = request.capture.copy( camera = camera, exposureTime = request.exposureTime + request.initialPause, savePath = Files.createTempDirectory("darv"), diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt index 44efa3684..7e898bf78 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt @@ -13,9 +13,9 @@ import java.time.Duration import java.time.temporal.ChronoUnit data class DARVStartRequest( - @JsonIgnoreProperties("camera", "focuser", "wheel") val capture: CameraStartCaptureRequest? = null, @JsonIgnore val camera: Camera? = null, @JsonIgnore val guideOutput: GuideOutput? = null, + @JsonIgnoreProperties("camera", "focuser", "wheel") val capture: CameraStartCaptureRequest = CameraStartCaptureRequest.EMPTY, @field:DurationMin(seconds = 1) @field:DurationMax(seconds = 600) @field:DurationUnit(ChronoUnit.SECONDS) val exposureTime: Duration = Duration.ZERO, @field:DurationMin(seconds = 1) @field:DurationMax(seconds = 60) @field:DurationUnit(ChronoUnit.SECONDS) val initialPause: Duration = Duration.ZERO, val direction: GuideDirection = GuideDirection.NORTH, diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt new file mode 100644 index 000000000..0d04cf6cd --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt @@ -0,0 +1,77 @@ +package nebulosa.api.alignment.polar.tppa + +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.messages.MessageEvent +import nebulosa.api.messages.MessageService +import nebulosa.api.solver.PlateSolverService +import nebulosa.batch.processing.JobExecution +import nebulosa.batch.processing.JobLauncher +import nebulosa.imaging.Image +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.mount.Mount +import nebulosa.log.debug +import nebulosa.log.loggerFor +import nebulosa.star.detection.StarDetector +import org.springframework.stereotype.Component +import java.util.* + +@Component +class TPPAExecutor( + private val jobLauncher: JobLauncher, + private val messageService: MessageService, + private val plateSolverService: PlateSolverService, + private val starDetector: StarDetector, +) : Consumer { + + private val jobExecutions = LinkedList() + + @Synchronized + fun execute(request: TPPAStartRequest) { + val camera = requireNotNull(request.camera) + val mount = requireNotNull(request.mount) + + check(!isRunning(camera, mount)) { "TPPA job is already running" } + + LOG.debug { "starting TPPA. request=%s".format(request) } + + val solver = plateSolverService.solverFor(request.plateSolverOptions) + + with(TPPAJob(request, solver, starDetector)) { + subscribe(this@TPPAExecutor) + val jobExecution = jobLauncher.launch(this) + jobExecutions.add(jobExecution) + } + } + + fun findJobExecution(camera: Camera, mount: Mount): JobExecution? { + for (i in jobExecutions.indices.reversed()) { + val jobExecution = jobExecutions[i] + val job = jobExecution.job as TPPAJob + + if (!jobExecution.isDone && job.camera === camera && job.mount === mount) { + return jobExecution + } + } + + return null + } + + @Synchronized + fun stop(camera: Camera, mount: Mount) { + val jobExecution = findJobExecution(camera, mount) ?: return + jobLauncher.stop(jobExecution) + } + + fun isRunning(camera: Camera, mount: Mount): Boolean { + return findJobExecution(camera, mount) != null + } + + override fun accept(event: MessageEvent) { + messageService.sendMessage(event) + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt new file mode 100644 index 000000000..0fb3bb4eb --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt @@ -0,0 +1,43 @@ +package nebulosa.api.alignment.polar.tppa + +import io.reactivex.rxjava3.subjects.PublishSubject +import nebulosa.api.cameras.AutoSubFolderMode +import nebulosa.api.cameras.CameraCaptureEventHandler +import nebulosa.api.cameras.CameraCaptureListener +import nebulosa.api.messages.MessageEvent +import nebulosa.batch.processing.PublishSubscribe +import nebulosa.batch.processing.SimpleJob +import nebulosa.imaging.Image +import nebulosa.indi.device.camera.FrameType +import nebulosa.plate.solving.PlateSolver +import nebulosa.star.detection.StarDetector +import java.nio.file.Files +import java.time.Duration + +data class TPPAJob( + @JvmField val request: TPPAStartRequest, + @JvmField val solver: PlateSolver, + @JvmField val starDetector: StarDetector, +) : SimpleJob(), PublishSubscribe, CameraCaptureListener { + + @JvmField val camera = requireNotNull(request.camera) + @JvmField val mount = requireNotNull(request.mount) + + @JvmField val cameraRequest = request.capture.copy( + camera = camera, + savePath = Files.createTempDirectory("tppa"), + exposureAmount = 1, exposureDelay = Duration.ZERO, + frameType = FrameType.LIGHT, autoSave = false, autoSubFolderMode = AutoSubFolderMode.OFF + ) + + override val subject = PublishSubject.create() + + private val cameraCaptureEventHandler = CameraCaptureEventHandler(this) + private val tppaStep = TPPAStep(solver, starDetector, mount, request, cameraRequest) + + init { + tppaStep.registerCameraCaptureListener(cameraCaptureEventHandler) + + add(tppaStep) + } +} 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 new file mode 100644 index 000000000..69e84e30f --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStartRequest.kt @@ -0,0 +1,21 @@ +package nebulosa.api.alignment.polar.tppa + +import com.fasterxml.jackson.annotation.JsonIgnore +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.indi.device.camera.Camera +import nebulosa.indi.device.mount.Mount + +data class TPPAStartRequest( + @JsonIgnore val camera: Camera? = null, + @JsonIgnore val mount: Mount? = null, + @JsonIgnoreProperties("camera", "focuser", "wheel") val capture: CameraStartCaptureRequest = CameraStartCaptureRequest.EMPTY, + @field:NotNull @Valid val plateSolverOptions: PlateSolverOptions = PlateSolverOptions.EMPTY, + val startFromCurrentPosition: Boolean = true, + val eastDirection: Boolean = true, + val refractionAdjustment: Boolean = false, + val stopTrackingWhenDone: Boolean = true, +) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt new file mode 100644 index 000000000..8e9fa9918 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt @@ -0,0 +1,75 @@ +package nebulosa.api.alignment.polar.tppa + +import nebulosa.alignment.polar.point.three.ThreePointPolarAlignment +import nebulosa.alignment.polar.point.three.ThreePointPolarAlignmentResult +import nebulosa.api.cameras.CameraCaptureListener +import nebulosa.api.cameras.CameraExposureStep +import nebulosa.api.cameras.CameraStartCaptureRequest +import nebulosa.api.mounts.MountSlewStep +import nebulosa.batch.processing.JobExecution +import nebulosa.batch.processing.Step +import nebulosa.batch.processing.StepExecution +import nebulosa.batch.processing.StepResult +import nebulosa.fits.Fits +import nebulosa.imaging.Image +import nebulosa.indi.device.mount.Mount +import nebulosa.math.deg +import nebulosa.plate.solving.PlateSolver +import nebulosa.star.detection.StarDetector + +data class TPPAStep( + private val solver: PlateSolver, + private val starDetector: StarDetector, + private val mount: Mount?, + private val request: TPPAStartRequest, + private val cameraRequest: CameraStartCaptureRequest = request.capture, +) : Step { + + private val cameraExposureStep = CameraExposureStep(cameraRequest) + private val alignment = ThreePointPolarAlignment(solver, starDetector) + @Volatile private var image: Image? = null + + fun registerCameraCaptureListener(listener: CameraCaptureListener): Boolean { + return cameraExposureStep.registerCameraCaptureListener(listener) + } + + fun unregisterCameraCaptureListener(listener: CameraCaptureListener): Boolean { + return cameraExposureStep.unregisterCameraCaptureListener(listener) + } + + override fun beforeJob(jobExecution: JobExecution) { + mount?.tracking(true) + } + + override fun afterJob(jobExecution: JobExecution) { + if (mount != null && request.stopTrackingWhenDone) { + mount.tracking(false) + } + } + + override fun execute(stepExecution: StepExecution): StepResult { + if (mount != null) { + if (alignment.state == ThreePointPolarAlignment.State.SECOND_MEASURE || + alignment.state == ThreePointPolarAlignment.State.THIRD_MEASURE + ) { + val slewStep = MountSlewStep(mount, mount.rightAscension + 10.deg, mount.declination) + slewStep.execute(stepExecution) + } + } + + cameraExposureStep.execute(stepExecution) + + val savedPath = cameraExposureStep.savedPath ?: return StepResult.FINISHED + image = Fits(savedPath).also(Fits::read).use { image?.load(it, false) ?: Image.open(it, false) } + val radius = if (mount == null) 0.0 else ThreePointPolarAlignment.DEFAULT_RADIUS + val result = alignment.align(savedPath, image!!, mount?.rightAscension ?: 0.0, mount?.declination ?: 0.0, radius) + + if (result === ThreePointPolarAlignmentResult.NeedMoreMeasure) { + return StepResult.CONTINUABLE + } else if (result === ThreePointPolarAlignmentResult.NoPlateSolution) { + return StepResult.FINISHED + } + + return StepResult.FINISHED + } +} 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 9cf868f73..b1f676b99 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt @@ -19,9 +19,12 @@ import nebulosa.guiding.Guider import nebulosa.guiding.phd2.PHD2Guider import nebulosa.hips2fits.Hips2FitsService import nebulosa.horizons.HorizonsService +import nebulosa.imaging.Image import nebulosa.phd2.client.PHD2Client import nebulosa.sbd.SmallBodyDatabaseService import nebulosa.simbad.SimbadService +import nebulosa.star.detection.StarDetector +import nebulosa.watney.star.detection.WatneyStarDetector import okhttp3.Cache import okhttp3.ConnectionPool import okhttp3.OkHttpClient @@ -139,6 +142,10 @@ class BeanConfiguration { @Bean fun asyncJobLauncher(threadPoolTaskExecutor: ThreadPoolTaskExecutor) = AsyncJobLauncher(threadPoolTaskExecutor) + @Bean + @Primary + fun watneyStarDetector(): StarDetector = WatneyStarDetector(computeHFD = true) + @Bean @Primary fun boxStore(dataPath: Path) = MyObjectBox.builder() diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt index 60c1cf3d4..56441875f 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt @@ -51,4 +51,9 @@ data class CameraStartCaptureRequest( inline val isLoop get() = exposureAmount <= 0 + + companion object { + + @JvmStatic val EMPTY = CameraStartCaptureRequest() + } } diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt index a7e289c48..e5f95e847 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt @@ -8,6 +8,7 @@ import nebulosa.api.connection.ConnectionService import nebulosa.api.framing.FramingService import nebulosa.api.framing.HipsSurveyType import nebulosa.fits.* +import nebulosa.imaging.Image import nebulosa.imaging.ImageChannel import nebulosa.imaging.algorithms.computation.Histogram import nebulosa.imaging.algorithms.computation.Statistics @@ -19,9 +20,9 @@ import nebulosa.math.* import nebulosa.sbd.SmallBodyDatabaseService import nebulosa.skycatalog.ClassificationType import nebulosa.star.detection.ImageStar -import nebulosa.watney.star.detection.WatneyStarDetector -import nebulosa.wcs.WCSException +import nebulosa.star.detection.StarDetector import nebulosa.wcs.WCS +import nebulosa.wcs.WCSException import org.springframework.http.HttpStatus import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor import org.springframework.stereotype.Service @@ -44,6 +45,7 @@ class ImageService( private val imageBucket: ImageBucket, private val threadPoolTaskExecutor: ThreadPoolTaskExecutor, private val connectionService: ConnectionService, + private val starDetector: StarDetector, ) { @Synchronized @@ -276,7 +278,7 @@ class ImageService( fun detectStars(path: Path): List { val (image) = imageBucket[path] ?: return emptyList() - return WATNEY_STAR_DETECTOR.detect(image) + return starDetector.detect(image) } fun histogram(path: Path, bitLength: Int = 16): IntArray { @@ -287,7 +289,6 @@ class ImageService( companion object { @JvmStatic private val LOG = loggerFor() - @JvmStatic private val WATNEY_STAR_DETECTOR = WatneyStarDetector(computeHFD = true) private const val COORDINATE_INTERPOLATION_DELTA = 24 } diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt new file mode 100644 index 000000000..aba3d1a41 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt @@ -0,0 +1,73 @@ +package nebulosa.api.mounts + +import nebulosa.batch.processing.Step +import nebulosa.batch.processing.StepExecution +import nebulosa.batch.processing.StepResult +import nebulosa.common.concurrency.latch.CountUpDownLatch +import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.mount.MountEvent +import nebulosa.indi.device.mount.MountSlewFailed +import nebulosa.indi.device.mount.MountSlewingChanged +import nebulosa.log.loggerFor +import nebulosa.math.Angle +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode + +data class MountSlewStep( + val mount: Mount, + val rightAscension: Angle, val declination: Angle, + val j2000: Boolean = false, val goTo: Boolean = true, +) : Step { + + private val latch = CountUpDownLatch() + + private val initialRA = mount.rightAscension + private val initialDEC = mount.declination + + @Subscribe(threadMode = ThreadMode.ASYNC) + fun onFilterWheelEvent(event: MountEvent) { + if (event is MountSlewingChanged) { + if (!mount.slewing && mount.rightAscension != initialRA && mount.declination != initialDEC) { + latch.reset() + } + } else if (event is MountSlewFailed) { + LOG.warn("failed to slew mount. mount={}", mount) + latch.reset() + } + } + + override fun execute(stepExecution: StepExecution): StepResult { + if (mount.connected && rightAscension.isFinite() && + declination.isFinite() && mount.rightAscension != initialRA + && mount.declination != initialDEC + ) { + EventBus.getDefault().register(this) + + latch.countUp() + + if (j2000) { + if (goTo) mount.goToJ2000(rightAscension, declination) + else mount.slewToJ2000(rightAscension, declination) + } else { + if (goTo) mount.goTo(rightAscension, declination) + else mount.slewTo(rightAscension, declination) + } + + latch.await() + + EventBus.getDefault().unregister(this) + } + + return StepResult.FINISHED + } + + override fun stop(mayInterruptIfRunning: Boolean) { + latch.reset() + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt index deb7ab00b..22daca0b9 100644 --- a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt +++ b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt @@ -14,4 +14,10 @@ data class PlateSolverOptions( val apiUrl: String = "", val apiKey: String = "", @field:DurationMin(seconds = 0) @field:DurationMax(minutes = 5) @field:DurationUnit(ChronoUnit.SECONDS) val timeout: Duration = Duration.ZERO, -) +) { + + companion object { + + @JvmStatic val EMPTY = PlateSolverOptions() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt index 3384ac90e..89e0ba376 100644 --- a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt +++ b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt @@ -7,6 +7,7 @@ import nebulosa.astrometrynet.nova.NovaAstrometryNetService import nebulosa.astrometrynet.plate.solving.LocalAstrometryNetPlateSolver import nebulosa.astrometrynet.plate.solving.NovaAstrometryNetPlateSolver import nebulosa.math.Angle +import nebulosa.plate.solving.PlateSolver import okhttp3.OkHttpClient import org.springframework.stereotype.Service import java.nio.file.Path @@ -26,24 +27,26 @@ class PlateSolverService( return ImageSolved(calibration) } + fun solverFor(options: PlateSolverOptions): PlateSolver { + return with(options) { + when (type) { + PlateSolverType.ASTAP -> AstapPlateSolver(executablePath!!) + PlateSolverType.ASTROMETRY_NET -> LocalAstrometryNetPlateSolver(executablePath!!) + PlateSolverType.ASTROMETRY_NET_ONLINE -> { + val key = "$apiUrl@$apiKey" + val service = NOVA_ASTROMETRY_NET_CACHE.getOrPut(key) { NovaAstrometryNetService(apiUrl, httpClient) } + NovaAstrometryNetPlateSolver(service, apiKey) + } + } + } + } + @Synchronized fun solve( options: PlateSolverOptions, path: Path, centerRA: Angle = 0.0, centerDEC: Angle = 0.0, radius: Angle = 0.0, - ) = with(options) { - val plateSolver = when (type) { - PlateSolverType.ASTAP -> AstapPlateSolver(executablePath!!) - PlateSolverType.ASTROMETRY_NET -> LocalAstrometryNetPlateSolver(executablePath!!) - PlateSolverType.ASTROMETRY_NET_ONLINE -> { - val key = "$apiUrl@$apiKey" - val service = NOVA_ASTROMETRY_NET_CACHE.getOrPut(key) { NovaAstrometryNetService(apiUrl, httpClient) } - NovaAstrometryNetPlateSolver(service, apiKey) - } - } - - plateSolver - .solve(path, null, centerRA, centerDEC, radius, 1, options.timeout.takeIf { it.toSeconds() > 0 }) - } + ) = solverFor(options) + .solve(path, null, centerRA, centerDEC, radius, 1, options.timeout.takeIf { it.toSeconds() > 0 }) companion object { diff --git a/nebulosa-alignment/build.gradle.kts b/nebulosa-alignment/build.gradle.kts index 8f0d8dcab..f8e339788 100644 --- a/nebulosa-alignment/build.gradle.kts +++ b/nebulosa-alignment/build.gradle.kts @@ -6,7 +6,6 @@ plugins { dependencies { api(project(":nebulosa-erfa")) api(project(":nebulosa-time")) - api(project(":nebulosa-indi-device")) api(project(":nebulosa-plate-solving")) api(project(":nebulosa-star-detection")) implementation(project(":nebulosa-log")) diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/PolarAlignment.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/PolarAlignment.kt deleted file mode 100644 index 57210a97a..000000000 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/PolarAlignment.kt +++ /dev/null @@ -1,8 +0,0 @@ -package nebulosa.alignment.polar - -import nebulosa.imaging.Image - -interface PolarAlignment { - - fun align(image: Image): T -} diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt index f8f40ab06..8178f1e5d 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt @@ -21,7 +21,6 @@ internal class Position { rightAscension: Angle, declination: Angle, longitude: Angle, latitude: Angle, ) { - val time = UTC.now() val ee = eraEe06a(time.tt.whole, time.tt.fraction) val (ri, di) = eraAtic13((rightAscension + ee).normalized, declination, time.tdb.whole, time.tdb.fraction) diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt index c40dd4134..1785e32a3 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt @@ -1,43 +1,53 @@ package nebulosa.alignment.polar.point.three -import nebulosa.alignment.polar.PolarAlignment import nebulosa.alignment.polar.point.three.ThreePointPolarAlignment.State.* import nebulosa.constants.DEG2RAD +import nebulosa.fits.declination +import nebulosa.fits.rightAscension import nebulosa.imaging.Image -import nebulosa.indi.device.mount.Mount +import nebulosa.math.Angle import nebulosa.math.Point2D import nebulosa.plate.solving.PlateSolution import nebulosa.plate.solving.PlateSolver import nebulosa.star.detection.StarDetector +import java.nio.file.Path data class ThreePointPolarAlignment( private val solver: PlateSolver, private val starDetector: StarDetector, - private val mount: Mount?, // null for manual mode. -) : PolarAlignment { +) { - private enum class State { + enum class State { FIRST_MEASURE, SECOND_MEASURE, THIRD_MEASURE, ADJUSTMENT_MEASURE, } - @Volatile private var state = FIRST_MEASURE private val solutions = HashMap(4) - override fun align(image: Image): ThreePointPolarAlignmentResult { + @Volatile var state = FIRST_MEASURE + private set + + fun align( + path: Path, image: Image, + rightAscension: Angle = image.header.rightAscension, + declination: Angle = image.header.declination, + radius: Angle = DEFAULT_RADIUS, + ): ThreePointPolarAlignmentResult { return when (state) { - FIRST_MEASURE -> measure(image, SECOND_MEASURE) - SECOND_MEASURE -> measure(image, THIRD_MEASURE) - THIRD_MEASURE -> measure(image, ADJUSTMENT_MEASURE) - ADJUSTMENT_MEASURE -> measure(image, ADJUSTMENT_MEASURE) + FIRST_MEASURE -> measure(path, image, SECOND_MEASURE, rightAscension, declination, radius) + SECOND_MEASURE -> measure(path, image, THIRD_MEASURE, rightAscension, declination, radius) + THIRD_MEASURE -> measure(path, image, ADJUSTMENT_MEASURE, rightAscension, declination, radius) + ADJUSTMENT_MEASURE -> measure(path, image, ADJUSTMENT_MEASURE, rightAscension, declination, radius) } } - private fun measure(image: Image, nextState: State): ThreePointPolarAlignmentResult { - val radius = if (mount == null) 0.0 else DEFAULT_RADIUS - val solution = solver.solve(null, image, mount?.rightAscension ?: 0.0, mount?.declination ?: 0.0, radius) + private fun measure( + path: Path?, image: Image?, nextState: State, + rightAscension: Angle, declination: Angle, radius: Angle, + ): ThreePointPolarAlignmentResult { + val solution = solver.solve(path, image, rightAscension, declination, radius) return if (!solution.solved) { ThreePointPolarAlignmentResult.NoPlateSolution @@ -63,7 +73,7 @@ data class ThreePointPolarAlignment( companion object { - private const val DEFAULT_RADIUS = 4 * DEG2RAD + const val DEFAULT_RADIUS: Angle = 4 * DEG2RAD @JvmStatic internal fun StarDetector.closestStarPosition(image: Image, reference: Point2D): Point2D { diff --git a/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt b/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt index ff2218a9f..1ae94055e 100644 --- a/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt +++ b/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt @@ -45,7 +45,7 @@ class AstapPlateSolver(path: Path) : PlateSolver { arguments["-z"] = downsampleFactor arguments["-fov"] = 0 // auto - if (radius.toDegrees >= 0.1) { + if (radius.toDegrees >= 0.1 && centerRA.isFinite() && centerDEC.isFinite()) { arguments["-ra"] = centerRA.toHours arguments["-spd"] = centerDEC.toDegrees + 90.0 arguments["-r"] = ceil(radius.toDegrees) diff --git a/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LocalAstrometryNetPlateSolver.kt b/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LocalAstrometryNetPlateSolver.kt index 51edea9ce..3ff6a5fd9 100644 --- a/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LocalAstrometryNetPlateSolver.kt +++ b/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LocalAstrometryNetPlateSolver.kt @@ -44,7 +44,7 @@ class LocalAstrometryNetPlateSolver(path: Path) : PlateSolver { arguments["--no-plots"] = null // args["--resort"] = null - if (radius.toDegrees >= 0.1) { + if (radius.toDegrees >= 0.1 && centerRA.isFinite() && centerDEC.isFinite()) { arguments["--ra"] = centerRA.toDegrees arguments["--dec"] = centerDEC.toDegrees arguments["--radius"] = radius.toDegrees diff --git a/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/NovaAstrometryNetPlateSolver.kt b/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/NovaAstrometryNetPlateSolver.kt index 1e602c7c3..13daa78f6 100644 --- a/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/NovaAstrometryNetPlateSolver.kt +++ b/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/NovaAstrometryNetPlateSolver.kt @@ -46,7 +46,7 @@ data class NovaAstrometryNetPlateSolver( ): PlateSolution { renewSession() - val blind = radius.toDegrees < 0.1 + val blind = radius.toDegrees < 0.1 || !centerRA.isFinite() || !centerDEC.isFinite() val upload = Upload( session = session!!.session, diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/CountUpDownLatch.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/CountUpDownLatch.kt index 6fcc292b4..7499f9448 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/CountUpDownLatch.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/CountUpDownLatch.kt @@ -6,11 +6,13 @@ import java.time.Duration import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.locks.AbstractQueuedSynchronizer +import java.util.function.Supplier import kotlin.math.max -class CountUpDownLatch(initialCount: Int = 0) : AtomicBoolean(initialCount == 0), CancellationListener { +class CountUpDownLatch(initialCount: Int = 0) : Supplier, CancellationListener { - private val sync = Sync(this) + private val latch = AtomicBoolean(initialCount == 0) + private val sync = Sync(latch) init { require(initialCount >= 0) { "initialCount < 0: $initialCount" } @@ -20,11 +22,15 @@ class CountUpDownLatch(initialCount: Int = 0) : AtomicBoolean(initialCount == 0) val count get() = sync.count + override fun get(): Boolean { + return latch.get() + } + @Synchronized fun countUp(n: Int = 1): Int { if (n >= 1) { sync.count += n - set(false) + latch.set(false) } return count @@ -79,7 +85,7 @@ class CountUpDownLatch(initialCount: Int = 0) : AtomicBoolean(initialCount == 0) val next = max(0, this - releases) if (compareAndSetState(this, next)) { - latch.set(next <= state) + latch.set(next <= this) return latch.get() } } diff --git a/nebulosa-common/src/test/kotlin/PauserTest.kt b/nebulosa-common/src/test/kotlin/PauserTest.kt new file mode 100644 index 000000000..9e523daba --- /dev/null +++ b/nebulosa-common/src/test/kotlin/PauserTest.kt @@ -0,0 +1,30 @@ +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import nebulosa.common.concurrency.latch.Pauser +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +class PauserTest : StringSpec() { + + init { + "pause and wait for unpause" { + val pauser = Pauser() + pauser.isPaused.shouldBeFalse() + pauser.pause() + pauser.isPaused.shouldBeTrue() + thread { Thread.sleep(1000); pauser.unpause() } + pauser.waitWhileIsPaused() + pauser.isPaused.shouldBeFalse() + } + "pause and not wait for unpause" { + val pauser = Pauser() + pauser.isPaused.shouldBeFalse() + pauser.pause() + pauser.isPaused.shouldBeTrue() + thread { Thread.sleep(1000); pauser.unpause() } + pauser.waitWhileIsPaused(500, TimeUnit.MILLISECONDS).shouldBeFalse() + pauser.isPaused.shouldBeTrue() + } + } +} From a7628beb964e6a886cae4505239ea576ed89383c Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 3 Feb 2024 15:00:13 -0300 Subject: [PATCH 04/87] [api][desktop]: Remove device properties from request data --- .../polar/PolarAlignmentController.kt | 8 +- .../alignment/polar/PolarAlignmentService.kt | 8 +- .../api/alignment/polar/darv/DARVExecutor.kt | 55 +++----------- .../api/alignment/polar/darv/DARVJob.kt | 23 +++--- .../alignment/polar/darv/DARVStartRequest.kt | 5 -- .../api/alignment/polar/tppa/TPPAExecutor.kt | 55 +++----------- .../api/alignment/polar/tppa/TPPAJob.kt | 12 +-- .../alignment/polar/tppa/TPPAStartRequest.kt | 5 -- .../api/alignment/polar/tppa/TPPAStep.kt | 6 +- ...aptureEvent.kt => CameraCaptureElapsed.kt} | 2 +- .../api/cameras/CameraCaptureExecutor.kt | 50 +++---------- .../api/cameras/CameraCaptureFinished.kt | 2 +- .../api/cameras/CameraCaptureIsWaiting.kt | 2 +- .../nebulosa/api/cameras/CameraCaptureJob.kt | 20 ++--- .../api/cameras/CameraCaptureStarted.kt | 2 +- .../nebulosa/api/cameras/CameraController.kt | 5 -- .../api/cameras/CameraExposureElapsed.kt | 2 +- .../api/cameras/CameraExposureEvent.kt | 3 - .../api/cameras/CameraExposureFinished.kt | 2 +- .../api/cameras/CameraExposureStarted.kt | 2 +- .../api/cameras/CameraExposureStep.kt | 13 ++-- .../api/cameras/CameraLoopExposureStep.kt | 4 +- .../nebulosa/api/cameras/CameraService.kt | 8 +- .../api/cameras/CameraStartCaptureRequest.kt | 6 -- .../api/cameras/CameraStartCaptureStep.kt | 3 + .../nebulosa/api/guiding/GuidePulseRequest.kt | 3 - .../nebulosa/api/guiding/GuidePulseStep.kt | 11 +-- .../kotlin/nebulosa/api/jobs/JobExecutor.kt | 64 ++++++++++++++++ .../api/sequencer/SequencerController.kt | 17 +++-- ...{SequencerEvent.kt => SequencerElapsed.kt} | 6 +- .../api/sequencer/SequencerExecutor.kt | 47 +++++------- .../nebulosa/api/sequencer/SequencerJob.kt | 52 +++++++------ .../api/sequencer/SequencerService.kt | 11 ++- .../api/wizard/flat/FlatWizardElapsed.kt | 4 +- .../api/wizard/flat/FlatWizardExecutor.kt | 39 +++------- .../nebulosa/api/wizard/flat/FlatWizardJob.kt | 26 ++++--- .../api/wizard/flat/FlatWizardService.kt | 3 +- .../api/wizard/flat/FlatWizardStep.kt | 5 +- .../src/app/alignment/alignment.component.ts | 6 +- desktop/src/app/camera/camera.component.ts | 7 +- .../app/filterwheel/filterwheel.component.ts | 7 +- .../app/flat-wizard/flat-wizard.component.ts | 4 +- .../app/sequencer/sequencer.component.html | 18 ++--- .../src/app/sequencer/sequencer.component.ts | 75 ++++--------------- .../camera-exposure.component.ts | 4 +- desktop/src/shared/services/api.service.ts | 13 ++-- .../shared/services/browser-window.service.ts | 4 +- .../src/shared/services/electron.service.ts | 8 +- desktop/src/shared/types/alignment.types.ts | 2 +- desktop/src/shared/types/camera.types.ts | 8 +- desktop/src/shared/types/flat-wizard.types.ts | 4 +- desktop/src/shared/types/sequencer.types.ts | 9 ++- desktop/src/shared/types/wheel.types.ts | 1 + .../kotlin/nebulosa/batch/processing/Job.kt | 4 + .../nebulosa/batch/processing/SimpleJob.kt | 53 ++++++++++--- 55 files changed, 371 insertions(+), 447 deletions(-) rename api/src/main/kotlin/nebulosa/api/cameras/{CameraCaptureEvent.kt => CameraCaptureElapsed.kt} (90%) delete mode 100644 api/src/main/kotlin/nebulosa/api/cameras/CameraExposureEvent.kt create mode 100644 api/src/main/kotlin/nebulosa/api/jobs/JobExecutor.kt rename api/src/main/kotlin/nebulosa/api/sequencer/{SequencerEvent.kt => SequencerElapsed.kt} (69%) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt index 3e6caad76..fb3e05b10 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt @@ -21,9 +21,7 @@ class PolarAlignmentController( fun darvStart( @DeviceOrEntityParam camera: Camera, @DeviceOrEntityParam guideOutput: GuideOutput, @RequestBody body: DARVStartRequest, - ) { - polarAlignmentService.darvStart(camera, guideOutput, body) - } + ) = polarAlignmentService.darvStart(camera, guideOutput, body) @PutMapping("darv/{camera}/{guideOutput}/stop") fun darvStop(@DeviceOrEntityParam camera: Camera, @DeviceOrEntityParam guideOutput: GuideOutput) { @@ -34,9 +32,7 @@ class PolarAlignmentController( fun tppaStart( @DeviceOrEntityParam camera: Camera, @DeviceOrEntityParam mount: Mount, @RequestBody body: TPPAStartRequest, - ) { - polarAlignmentService.tppaStart(camera, mount, body) - } + ) = polarAlignmentService.tppaStart(camera, mount, body) @PutMapping("tppa/{camera}/{mount}/stop") fun tppaStop(@DeviceOrEntityParam camera: Camera, @DeviceOrEntityParam mount: Mount) { diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt index 118f63c71..bcb0a034a 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt @@ -15,20 +15,20 @@ class PolarAlignmentService( private val tppaExecutor: TPPAExecutor, ) { - fun darvStart(camera: Camera, guideOutput: GuideOutput, darvStartRequest: DARVStartRequest) { + fun darvStart(camera: Camera, guideOutput: GuideOutput, darvStartRequest: DARVStartRequest): String { check(camera.connected) { "camera not connected" } check(guideOutput.connected) { "guide output not connected" } - darvExecutor.execute(darvStartRequest.copy(camera = camera, guideOutput = guideOutput)) + return darvExecutor.execute(camera, guideOutput, darvStartRequest) } fun darvStop(camera: Camera, guideOutput: GuideOutput) { darvExecutor.stop(camera, guideOutput) } - fun tppaStart(camera: Camera, mount: Mount, tppaStartRequest: TPPAStartRequest) { + fun tppaStart(camera: Camera, mount: Mount, tppaStartRequest: TPPAStartRequest): String { check(camera.connected) { "camera not connected" } check(mount.connected) { "mount not connected" } - tppaExecutor.execute(tppaStartRequest.copy(camera = camera, mount = mount)) + return tppaExecutor.execute(camera, mount, tppaStartRequest) } fun tppaStop(camera: Camera, mount: Mount) { diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt index a71e22e04..7250060d3 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt @@ -1,69 +1,38 @@ package nebulosa.api.alignment.polar.darv -import io.reactivex.rxjava3.functions.Consumer -import nebulosa.api.messages.MessageEvent +import nebulosa.api.jobs.JobExecutor import nebulosa.api.messages.MessageService -import nebulosa.batch.processing.JobExecution import nebulosa.batch.processing.JobLauncher import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.guide.GuideOutput -import nebulosa.log.debug +import nebulosa.log.info import nebulosa.log.loggerFor import org.springframework.stereotype.Component -import java.util.* /** * @see Reference */ @Component class DARVExecutor( - private val jobLauncher: JobLauncher, + override val jobLauncher: JobLauncher, private val messageService: MessageService, -) : Consumer { - - private val jobExecutions = LinkedList() +) : JobExecutor() { @Synchronized - fun execute(request: DARVStartRequest) { - val camera = requireNotNull(request.camera) - val guideOutput = requireNotNull(request.guideOutput) - - check(!isRunning(camera, guideOutput)) { "DARV job is already running" } - - LOG.debug { "starting DARV. request=%s".format(request) } - - with(DARVJob(request)) { - subscribe(this@DARVExecutor) - val jobExecution = jobLauncher.launch(this) - jobExecutions.add(jobExecution) - } - } + fun execute(camera: Camera, guideOutput: GuideOutput, request: DARVStartRequest): String { + check(findJobExecutionWithAny(camera, guideOutput) == null) { "DARV job is already running" } - fun findJobExecution(camera: Camera, guideOutput: GuideOutput): JobExecution? { - for (i in jobExecutions.indices.reversed()) { - val jobExecution = jobExecutions[i] - val job = jobExecution.job as DARVJob + LOG.info { "starting DARV. camera=$camera, guideOutput=$guideOutput, request=$request" } - if (!jobExecution.isDone && job.camera === camera && job.guideOutput === guideOutput) { - return jobExecution - } + return with(DARVJob(camera, guideOutput, request)) { + subscribe(messageService::sendMessage) + register(jobLauncher.launch(this)) + id } - - return null } - @Synchronized fun stop(camera: Camera, guideOutput: GuideOutput) { - val jobExecution = findJobExecution(camera, guideOutput) ?: return - jobLauncher.stop(jobExecution) - } - - fun isRunning(camera: Camera, guideOutput: GuideOutput): Boolean { - return findJobExecution(camera, guideOutput) != null - } - - override fun accept(event: MessageEvent) { - messageService.sendMessage(event) + stopWithAny(camera, guideOutput) } companion object { diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt index 00a06f0a7..4ea3a5b95 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt @@ -14,20 +14,21 @@ import nebulosa.batch.processing.ExecutionContext.Companion.getDouble import nebulosa.batch.processing.ExecutionContext.Companion.getDuration import nebulosa.batch.processing.delay.DelayStep import nebulosa.batch.processing.delay.DelayStepListener +import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.FrameType +import nebulosa.indi.device.guide.GuideOutput import java.nio.file.Files import java.time.Duration data class DARVJob( + @JvmField val camera: Camera, + @JvmField val guideOutput: GuideOutput, @JvmField val request: DARVStartRequest, ) : SimpleJob(), PublishSubscribe, CameraCaptureListener, GuidePulseListener, DelayStepListener { - @JvmField val camera = requireNotNull(request.camera) - @JvmField val guideOutput = requireNotNull(request.guideOutput) @JvmField val direction = if (request.reversed) request.direction.reversed else request.direction @JvmField val cameraRequest = request.capture.copy( - camera = camera, exposureTime = request.exposureTime + request.initialPause, savePath = Files.createTempDirectory("darv"), exposureAmount = 1, exposureDelay = Duration.ZERO, @@ -37,23 +38,23 @@ data class DARVJob( override val subject = PublishSubject.create() init { - val cameraExposureStep = CameraExposureStep(cameraRequest) + val cameraExposureStep = CameraExposureStep(camera, cameraRequest) cameraExposureStep.registerCameraCaptureListener(this) val initialPauseDelayStep = DelayStep(request.initialPause) initialPauseDelayStep.registerDelayStepListener(this) val guidePulseDuration = request.exposureTime.dividedBy(2L) - val forwardGuidePulseRequest = GuidePulseRequest(guideOutput, direction, guidePulseDuration) - val forwardGuidePulseStep = GuidePulseStep(forwardGuidePulseRequest) + val forwardGuidePulseRequest = GuidePulseRequest(direction, guidePulseDuration) + val forwardGuidePulseStep = GuidePulseStep(guideOutput, forwardGuidePulseRequest) forwardGuidePulseStep.registerGuidePulseListener(this) - val backwardGuidePulseRequest = GuidePulseRequest(guideOutput, direction.reversed, guidePulseDuration) - val backwardGuidePulseStep = GuidePulseStep(backwardGuidePulseRequest) + val backwardGuidePulseRequest = GuidePulseRequest(direction.reversed, guidePulseDuration) + val backwardGuidePulseStep = GuidePulseStep(guideOutput, backwardGuidePulseRequest) backwardGuidePulseStep.registerGuidePulseListener(this) val guideFlow = SimpleFlowStep(initialPauseDelayStep, forwardGuidePulseStep, backwardGuidePulseStep) - add(SimpleSplitStep(cameraExposureStep, guideFlow)) + register(SimpleSplitStep(cameraExposureStep, guideFlow)) } override fun beforeJob(jobExecution: JobExecution) { @@ -81,4 +82,8 @@ data class DARVJob( val progress = stepExecution.context.getDouble(DelayStep.PROGRESS) onNext(DARVInitialPauseElapsed(camera, guideOutput, remainingTime, progress)) } + + override fun contains(data: Any): Boolean { + return data === camera || data === guideOutput || super.contains(data) + } } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt index 7e898bf78..5dcd20b5a 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt @@ -1,11 +1,8 @@ package nebulosa.api.alignment.polar.darv -import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnoreProperties import nebulosa.api.cameras.CameraStartCaptureRequest import nebulosa.guiding.GuideDirection -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.guide.GuideOutput import org.hibernate.validator.constraints.time.DurationMax import org.hibernate.validator.constraints.time.DurationMin import org.springframework.boot.convert.DurationUnit @@ -13,8 +10,6 @@ import java.time.Duration import java.time.temporal.ChronoUnit data class DARVStartRequest( - @JsonIgnore val camera: Camera? = null, - @JsonIgnore val guideOutput: GuideOutput? = null, @JsonIgnoreProperties("camera", "focuser", "wheel") val capture: CameraStartCaptureRequest = CameraStartCaptureRequest.EMPTY, @field:DurationMin(seconds = 1) @field:DurationMax(seconds = 600) @field:DurationUnit(ChronoUnit.SECONDS) val exposureTime: Duration = Duration.ZERO, @field:DurationMin(seconds = 1) @field:DurationMax(seconds = 60) @field:DurationUnit(ChronoUnit.SECONDS) val initialPause: Duration = Duration.ZERO, diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt index 0d04cf6cd..a81bff22f 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt @@ -1,73 +1,42 @@ package nebulosa.api.alignment.polar.tppa -import io.reactivex.rxjava3.functions.Consumer -import nebulosa.api.messages.MessageEvent +import nebulosa.api.jobs.JobExecutor import nebulosa.api.messages.MessageService import nebulosa.api.solver.PlateSolverService -import nebulosa.batch.processing.JobExecution import nebulosa.batch.processing.JobLauncher import nebulosa.imaging.Image import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.mount.Mount -import nebulosa.log.debug +import nebulosa.log.info import nebulosa.log.loggerFor import nebulosa.star.detection.StarDetector import org.springframework.stereotype.Component -import java.util.* @Component class TPPAExecutor( - private val jobLauncher: JobLauncher, + override val jobLauncher: JobLauncher, private val messageService: MessageService, private val plateSolverService: PlateSolverService, private val starDetector: StarDetector, -) : Consumer { - - private val jobExecutions = LinkedList() +) : JobExecutor() { @Synchronized - fun execute(request: TPPAStartRequest) { - val camera = requireNotNull(request.camera) - val mount = requireNotNull(request.mount) - - check(!isRunning(camera, mount)) { "TPPA job is already running" } + fun execute(camera: Camera, mount: Mount, request: TPPAStartRequest): String { + check(findJobExecutionWithAny(camera, mount) == null) { "TPPA job is already running" } - LOG.debug { "starting TPPA. request=%s".format(request) } + LOG.info { "starting TPPA. camera=$camera, mount=$mount, request=$request" } val solver = plateSolverService.solverFor(request.plateSolverOptions) - with(TPPAJob(request, solver, starDetector)) { - subscribe(this@TPPAExecutor) - val jobExecution = jobLauncher.launch(this) - jobExecutions.add(jobExecution) - } - } - - fun findJobExecution(camera: Camera, mount: Mount): JobExecution? { - for (i in jobExecutions.indices.reversed()) { - val jobExecution = jobExecutions[i] - val job = jobExecution.job as TPPAJob - - if (!jobExecution.isDone && job.camera === camera && job.mount === mount) { - return jobExecution - } + return with(TPPAJob(camera, request, solver, starDetector, mount)) { + subscribe(messageService::sendMessage) + register(jobLauncher.launch(this)) + id } - - return null } - @Synchronized fun stop(camera: Camera, mount: Mount) { - val jobExecution = findJobExecution(camera, mount) ?: return - jobLauncher.stop(jobExecution) - } - - fun isRunning(camera: Camera, mount: Mount): Boolean { - return findJobExecution(camera, mount) != null - } - - override fun accept(event: MessageEvent) { - messageService.sendMessage(event) + stopWithAny(camera, mount) } companion object { diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt index 0fb3bb4eb..76fd816ba 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt @@ -8,23 +8,23 @@ import nebulosa.api.messages.MessageEvent import nebulosa.batch.processing.PublishSubscribe import nebulosa.batch.processing.SimpleJob import nebulosa.imaging.Image +import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.FrameType +import nebulosa.indi.device.mount.Mount import nebulosa.plate.solving.PlateSolver import nebulosa.star.detection.StarDetector import java.nio.file.Files import java.time.Duration data class TPPAJob( + @JvmField val camera: Camera, @JvmField val request: TPPAStartRequest, @JvmField val solver: PlateSolver, @JvmField val starDetector: StarDetector, + @JvmField val mount: Mount? = null, ) : SimpleJob(), PublishSubscribe, CameraCaptureListener { - @JvmField val camera = requireNotNull(request.camera) - @JvmField val mount = requireNotNull(request.mount) - @JvmField val cameraRequest = request.capture.copy( - camera = camera, savePath = Files.createTempDirectory("tppa"), exposureAmount = 1, exposureDelay = Duration.ZERO, frameType = FrameType.LIGHT, autoSave = false, autoSubFolderMode = AutoSubFolderMode.OFF @@ -33,11 +33,11 @@ data class TPPAJob( override val subject = PublishSubject.create() private val cameraCaptureEventHandler = CameraCaptureEventHandler(this) - private val tppaStep = TPPAStep(solver, starDetector, mount, request, cameraRequest) + private val tppaStep = TPPAStep(camera, solver, starDetector, request, mount, cameraRequest) init { tppaStep.registerCameraCaptureListener(cameraCaptureEventHandler) - add(tppaStep) + register(tppaStep) } } 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 69e84e30f..4b55431ec 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 @@ -1,17 +1,12 @@ package nebulosa.api.alignment.polar.tppa -import com.fasterxml.jackson.annotation.JsonIgnore 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.indi.device.camera.Camera -import nebulosa.indi.device.mount.Mount data class TPPAStartRequest( - @JsonIgnore val camera: Camera? = null, - @JsonIgnore val mount: Mount? = null, @JsonIgnoreProperties("camera", "focuser", "wheel") val capture: CameraStartCaptureRequest = CameraStartCaptureRequest.EMPTY, @field:NotNull @Valid val plateSolverOptions: PlateSolverOptions = PlateSolverOptions.EMPTY, val startFromCurrentPosition: Boolean = true, diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt index 8e9fa9918..2f9fbd51e 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt @@ -12,20 +12,22 @@ import nebulosa.batch.processing.StepExecution import nebulosa.batch.processing.StepResult import nebulosa.fits.Fits import nebulosa.imaging.Image +import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.mount.Mount import nebulosa.math.deg import nebulosa.plate.solving.PlateSolver import nebulosa.star.detection.StarDetector data class TPPAStep( + private val camera: Camera, private val solver: PlateSolver, private val starDetector: StarDetector, - private val mount: Mount?, private val request: TPPAStartRequest, + private val mount: Mount? = null, private val cameraRequest: CameraStartCaptureRequest = request.capture, ) : Step { - private val cameraExposureStep = CameraExposureStep(cameraRequest) + private val cameraExposureStep = CameraExposureStep(camera, cameraRequest) private val alignment = ThreePointPolarAlignment(solver, starDetector) @Volatile private var image: Image? = null diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureElapsed.kt similarity index 90% rename from api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt rename to api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureElapsed.kt index fbf54a8c9..1e9121050 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureElapsed.kt @@ -6,7 +6,7 @@ import nebulosa.indi.device.camera.Camera import java.nio.file.Path import java.time.Duration -sealed interface CameraCaptureEvent : MessageEvent, JobExecutionEvent { +sealed interface CameraCaptureElapsed : MessageEvent, JobExecutionEvent { val camera: Camera diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt index 0eb132a04..2e8013ab8 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt @@ -1,65 +1,35 @@ package nebulosa.api.cameras -import io.reactivex.rxjava3.functions.Consumer -import nebulosa.api.messages.MessageEvent +import nebulosa.api.jobs.JobExecutor import nebulosa.api.messages.MessageService -import nebulosa.batch.processing.JobExecution import nebulosa.batch.processing.JobLauncher import nebulosa.guiding.Guider import nebulosa.indi.device.camera.Camera -import nebulosa.log.debug +import nebulosa.log.info import nebulosa.log.loggerFor import org.springframework.stereotype.Component -import java.util.* @Component class CameraCaptureExecutor( private val messageService: MessageService, private val guider: Guider, - private val jobLauncher: JobLauncher, -) : Consumer { - - private val jobExecutions = LinkedList() + override val jobLauncher: JobLauncher, +) : JobExecutor() { @Synchronized - fun execute(request: CameraStartCaptureRequest) { - val camera = requireNotNull(request.camera) - + fun execute(camera: Camera, request: CameraStartCaptureRequest) { check(camera.connected) { "camera is not connected" } - check(!isCapturing(camera)) { "job is already running for camera: [${camera.name}]" } + check(findJobExecutionWithAny(camera) == null) { "Camera Capture job is already running" } - LOG.debug { "starting camera capture. request=$request" } + LOG.info { "starting camera capture. camera=$camera, request=$request" } - val cameraCaptureJob = CameraCaptureJob(request, guider) - cameraCaptureJob.subscribe(this) + val cameraCaptureJob = CameraCaptureJob(camera, request, guider) + cameraCaptureJob.subscribe(messageService::sendMessage) jobExecutions.add(jobLauncher.launch(cameraCaptureJob)) } - fun findJobExecution(camera: Camera): JobExecution? { - for (i in jobExecutions.indices.reversed()) { - val jobExecution = jobExecutions[i] - val job = jobExecution.job as CameraCaptureJob - - if (!jobExecution.isDone && job.camera === camera) { - return jobExecution - } - } - - return null - } - - @Synchronized fun stop(camera: Camera) { - val jobExecution = findJobExecution(camera) ?: return - jobLauncher.stop(jobExecution) - } - - fun isCapturing(camera: Camera): Boolean { - return findJobExecution(camera) != null - } - - override fun accept(event: MessageEvent) { - messageService.sendMessage(event) + stopWithAny(camera) } companion object { diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureFinished.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureFinished.kt index 101155307..522733948 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureFinished.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureFinished.kt @@ -11,7 +11,7 @@ data class CameraCaptureFinished( override val exposureAmount: Int, override val captureElapsedTime: Duration, val aborted: Boolean, -) : CameraCaptureEvent { +) : CameraCaptureElapsed { override val exposureCount = exposureAmount override val captureProgress = 1.0 diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureIsWaiting.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureIsWaiting.kt index 62908f063..5e6cb8c51 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureIsWaiting.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureIsWaiting.kt @@ -16,7 +16,7 @@ data class CameraCaptureIsWaiting( override val waitProgress: Double, override val waitRemainingTime: Duration, override val state: CameraCaptureState = CameraCaptureState.WAITING, -) : CameraCaptureEvent { +) : CameraCaptureElapsed { override val exposureProgress = 1.0 override val exposureRemainingTime = Duration.ZERO!! diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt index ba130ce39..718f378be 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt @@ -9,21 +9,21 @@ import nebulosa.batch.processing.SimpleJob import nebulosa.batch.processing.SimpleSplitStep import nebulosa.batch.processing.delay.DelayStep import nebulosa.guiding.Guider +import nebulosa.indi.device.camera.Camera data class CameraCaptureJob( + @JvmField val camera: Camera, @JvmField val request: CameraStartCaptureRequest, @JvmField val guider: Guider, ) : SimpleJob(), PublishSubscribe { private val cameraCaptureEventHandler = CameraCaptureEventHandler(this) - @JvmField val camera = requireNotNull(request.camera) - override val subject = PublishSubject.create() init { - val cameraExposureStep = if (request.isLoop) CameraLoopExposureStep(request) - else CameraExposureStep(request) + val cameraExposureStep = if (request.isLoop) CameraLoopExposureStep(camera, request) + else CameraExposureStep(camera, request) if (cameraExposureStep is CameraExposureStep) { val ditherStep = DitherAfterExposureStep(request.dither, guider) @@ -34,16 +34,16 @@ data class CameraCaptureJob( waitForSettleStep.registerWaitForSettleListener(cameraExposureStep) cameraDelayStep.registerDelayStepListener(cameraExposureStep) - add(waitForSettleStep) - add(cameraExposureStep) + register(waitForSettleStep) + register(cameraExposureStep) repeat(request.exposureAmount - 1) { - add(delayAndWaitForSettleStep) - add(cameraExposureStep) - add(ditherStep) + register(delayAndWaitForSettleStep) + register(cameraExposureStep) + register(ditherStep) } } else { - add(cameraExposureStep) + register(cameraExposureStep) } cameraExposureStep.registerCameraCaptureListener(cameraCaptureEventHandler) diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureStarted.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureStarted.kt index ea5217906..d17db6ad0 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureStarted.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureStarted.kt @@ -11,7 +11,7 @@ data class CameraCaptureStarted( override val exposureAmount: Int, override val captureRemainingTime: Duration, override val exposureRemainingTime: Duration, -) : CameraCaptureEvent { +) : CameraCaptureElapsed { override val exposureCount = 1 override val captureElapsedTime = Duration.ZERO!! diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt index 05b50d9a7..c0f91c5b2 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt @@ -34,11 +34,6 @@ class CameraController( cameraService.disconnect(camera) } - @GetMapping("{camera}/capturing") - fun isCapturing(@DeviceOrEntityParam camera: Camera): Boolean { - return cameraService.isCapturing(camera) - } - @PutMapping("{camera}/cooler") fun cooler( @DeviceOrEntityParam camera: Camera, diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureElapsed.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureElapsed.kt index 5073bec96..705e84ab7 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureElapsed.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureElapsed.kt @@ -15,7 +15,7 @@ data class CameraExposureElapsed( override val captureRemainingTime: Duration, override val exposureProgress: Double, override val exposureRemainingTime: Duration, -) : CameraExposureEvent { +) : CameraCaptureElapsed { override val state = CameraCaptureState.EXPOSURING override val waitProgress = 0.0 diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureEvent.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureEvent.kt deleted file mode 100644 index b2b004111..000000000 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureEvent.kt +++ /dev/null @@ -1,3 +0,0 @@ -package nebulosa.api.cameras - -sealed interface CameraExposureEvent : CameraCaptureEvent diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureFinished.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureFinished.kt index bd691428a..98a7cfe50 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureFinished.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureFinished.kt @@ -15,7 +15,7 @@ data class CameraExposureFinished( override val captureProgress: Double, override val captureRemainingTime: Duration, override val savePath: Path, -) : CameraExposureEvent { +) : CameraCaptureElapsed { override val state = CameraCaptureState.EXPOSURE_FINISHED override val exposureProgress = 0.0 diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStarted.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStarted.kt index 44d9601fa..f53797f7f 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStarted.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStarted.kt @@ -14,7 +14,7 @@ data class CameraExposureStarted( override val captureProgress: Double, override val captureRemainingTime: Duration, override val exposureRemainingTime: Duration, -) : CameraExposureEvent { +) : CameraCaptureElapsed { override val state = CameraCaptureState.EXPOSURE_STARTED override val exposureProgress = 0.0 diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt index 8d005df3b..eeac72a3d 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt @@ -25,9 +25,10 @@ import java.time.format.DateTimeFormatter import kotlin.io.path.createParentDirectories import kotlin.io.path.outputStream -data class CameraExposureStep(override val request: CameraStartCaptureRequest) : CameraStartCaptureStep, DelayStepListener, WaitForSettleListener { - - @JvmField val camera = requireNotNull(request.camera) +data class CameraExposureStep( + override val camera: Camera, + override val request: CameraStartCaptureRequest, +) : CameraStartCaptureStep, DelayStepListener, WaitForSettleListener { @JvmField val exposureTime = request.exposureTime @JvmField val exposureAmount = request.exposureAmount @@ -156,7 +157,7 @@ data class CameraExposureStep(override val request: CameraStartCaptureRequest) : private fun save(stream: InputStream) { try { - savedPath = request.makeSavePath() + savedPath = request.makeSavePath(camera) LOG.info("saving FITS. path={}", savedPath) @@ -209,14 +210,14 @@ data class CameraExposureStep(override val request: CameraStartCaptureRequest) : @JvmStatic private val DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd.HHmmssSSS") @JvmStatic - fun CameraStartCaptureRequest.makeSavePath(autoSave: Boolean = this.autoSave): Path { + fun CameraStartCaptureRequest.makeSavePath(camera: Camera, autoSave: Boolean = this.autoSave): Path { return if (autoSave) { val now = LocalDateTime.now() val savePath = autoSubFolderMode.pathFor(savePath!!, now) val fileName = "%s-%s.fits".format(now.format(DATE_TIME_FORMAT), frameType) Path.of("$savePath", fileName) } else { - val fileName = "%s.fits".format(camera!!.name) + val fileName = "%s.fits".format(camera.name) Path.of("$savePath", fileName) } } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraLoopExposureStep.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraLoopExposureStep.kt index 0fe19c6c7..bc6f968cb 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraLoopExposureStep.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraLoopExposureStep.kt @@ -4,12 +4,14 @@ import nebulosa.batch.processing.JobExecution import nebulosa.batch.processing.StepExecution import nebulosa.batch.processing.StepResult import nebulosa.batch.processing.delay.DelayStep +import nebulosa.indi.device.camera.Camera data class CameraLoopExposureStep( + override val camera: Camera, override val request: CameraStartCaptureRequest, ) : CameraStartCaptureStep { - private val cameraExposureStep = CameraExposureStep(request) + private val cameraExposureStep = CameraExposureStep(camera, request) private val delayStep = DelayStep(request.exposureDelay) override val savedPath diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt index e61cd8135..2d88a4cb4 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt @@ -20,10 +20,6 @@ class CameraService( camera.disconnect() } - fun isCapturing(camera: Camera): Boolean { - return cameraCaptureExecutor.isCapturing(camera) - } - fun setpointTemperature(camera: Camera, temperature: Double) { camera.temperature(temperature) } @@ -34,14 +30,12 @@ class CameraService( @Synchronized fun startCapture(camera: Camera, request: CameraStartCaptureRequest) { - if (isCapturing(camera)) return - val savePath = request.savePath ?.takeIf { "$it".isNotBlank() && it.exists() && it.isDirectory() } ?: Path.of("$capturesPath", camera.name, request.frameType.name) cameraCaptureExecutor - .execute(request.copy(camera = camera, savePath = savePath)) + .execute(camera, request.copy(savePath = savePath)) } fun abortCapture(camera: Camera) { diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt index 56441875f..9482918b4 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt @@ -6,10 +6,7 @@ import jakarta.validation.constraints.Positive import jakarta.validation.constraints.PositiveOrZero import nebulosa.api.beans.converters.time.DurationDeserializer import nebulosa.api.guiding.DitherAfterExposureRequest -import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.FrameType -import nebulosa.indi.device.filterwheel.FilterWheel -import nebulosa.indi.device.focuser.Focuser import org.hibernate.validator.constraints.Range import org.hibernate.validator.constraints.time.DurationMax import org.hibernate.validator.constraints.time.DurationMin @@ -21,7 +18,6 @@ import java.time.temporal.ChronoUnit data class CameraStartCaptureRequest( val enabled: Boolean = true, // Capture. - val camera: Camera? = null, @field:DurationMin(nanos = 1000L) @field:DurationMax(minutes = 60L) val exposureTime: Duration = Duration.ZERO, @field:Range(min = 0L, max = 1000L) val exposureAmount: Int = 1, // 0 = looping @field:JsonDeserialize(using = DurationDeserializer::class) @field:DurationUnit(ChronoUnit.SECONDS) @@ -41,11 +37,9 @@ data class CameraStartCaptureRequest( val autoSubFolderMode: AutoSubFolderMode = AutoSubFolderMode.OFF, @field:Valid val dither: DitherAfterExposureRequest = DitherAfterExposureRequest.DISABLED, // Filter Wheel. - val wheel: FilterWheel? = null, val filterPosition: Int = 0, val shutterPosition: Int = 0, // Focuser. - val focuser: Focuser? = null, val focusOffset: Int = 0, ) { diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureStep.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureStep.kt index 0110edce6..86c6e67ab 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureStep.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureStep.kt @@ -1,10 +1,13 @@ package nebulosa.api.cameras import nebulosa.batch.processing.Step +import nebulosa.indi.device.camera.Camera import java.nio.file.Path sealed interface CameraStartCaptureStep : Step { + val camera: Camera + val request: CameraStartCaptureRequest val savedPath: Path? diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseRequest.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseRequest.kt index 3badd2dbe..3b6d2134a 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseRequest.kt @@ -1,12 +1,9 @@ package nebulosa.api.guiding -import com.fasterxml.jackson.annotation.JsonIgnore import nebulosa.guiding.GuideDirection -import nebulosa.indi.device.guide.GuideOutput import java.time.Duration data class GuidePulseRequest( - @JsonIgnore val guideOutput: GuideOutput? = null, val direction: GuideDirection, val duration: Duration, ) diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseStep.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseStep.kt index 0894f392b..56fcc0fc8 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseStep.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseStep.kt @@ -10,7 +10,10 @@ import nebulosa.guiding.GuideDirection import nebulosa.indi.device.guide.GuideOutput import java.time.Duration -data class GuidePulseStep(@JvmField val request: GuidePulseRequest) : Step, DelayStepListener { +data class GuidePulseStep( + @JvmField val guideOutput: GuideOutput, + @JvmField val request: GuidePulseRequest, +) : Step, DelayStepListener { private val listeners = LinkedHashSet() private val delayStep = DelayStep(request.duration) @@ -28,8 +31,6 @@ data class GuidePulseStep(@JvmField val request: GuidePulseRequest) : Step, Dela } override fun execute(stepExecution: StepExecution): StepResult { - val guideOutput = requireNotNull(request.guideOutput) - if (guideOutput.pulseGuide(request.duration, request.direction)) { delayStep.execute(stepExecution) } @@ -38,11 +39,11 @@ data class GuidePulseStep(@JvmField val request: GuidePulseRequest) : Step, Dela } override fun afterJob(jobExecution: JobExecution) { - request.guideOutput?.stop() + guideOutput.stop() } override fun stop(mayInterruptIfRunning: Boolean) { - request.guideOutput?.stop() + guideOutput.stop() delayStep.stop() } diff --git a/api/src/main/kotlin/nebulosa/api/jobs/JobExecutor.kt b/api/src/main/kotlin/nebulosa/api/jobs/JobExecutor.kt new file mode 100644 index 000000000..d7ca06aa7 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/jobs/JobExecutor.kt @@ -0,0 +1,64 @@ +package nebulosa.api.jobs + +import nebulosa.batch.processing.Job +import nebulosa.batch.processing.JobExecution +import nebulosa.batch.processing.JobLauncher +import java.util.* + +abstract class JobExecutor { + + protected abstract val jobLauncher: JobLauncher + + @PublishedApi internal val jobExecutions = LinkedList() + + protected fun register(jobExecution: JobExecution) { + jobExecutions.add(jobExecution) + } + + protected inline fun findJobExecutionWith(test: Job.() -> Boolean): JobExecution? { + for (i in jobExecutions.indices.reversed()) { + val jobExecution = jobExecutions[i] + val job = jobExecution.job + + if (!jobExecution.isDone && job.test()) { + return jobExecution + } + } + + return null + } + + protected fun findJobExecutionWithAll(vararg data: Any): JobExecution? { + return findJobExecutionWith { data.all { it in this } } + } + + protected fun findJobExecutionWithAny(vararg data: Any): JobExecution? { + return findJobExecutionWith { data.any { it in this } } + } + + fun findJobExecution(id: String): JobExecution? { + return jobExecutions.find { it.job.id == id } + } + + @Synchronized + protected fun stopWithAll(vararg data: Any) { + val jobExecution = findJobExecutionWithAll(*data) ?: return + jobLauncher.stop(jobExecution) + } + + @Synchronized + protected fun stopWithAny(vararg data: Any) { + val jobExecution = findJobExecutionWithAny(*data) ?: return + jobLauncher.stop(jobExecution) + } + + @Synchronized + fun stop(id: String) { + val jobExecution = findJobExecution(id) ?: return + jobLauncher.stop(jobExecution) + } + + fun isRunning(id: String): Boolean { + return findJobExecution(id) != null + } +} diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt index 8b46bc006..2c5946eb1 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt @@ -1,6 +1,8 @@ package nebulosa.api.sequencer import jakarta.validation.Valid +import nebulosa.api.beans.converters.indi.DeviceOrEntityParam +import nebulosa.indi.device.camera.Camera import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -12,13 +14,16 @@ class SequencerController( private val sequencerService: SequencerService, ) { - @PutMapping("start") - fun startSequencer(@RequestBody @Valid body: SequencePlanRequest) { - sequencerService.startSequencer(body) + @PutMapping("{camera}/start") + fun startSequencer( + @DeviceOrEntityParam camera: Camera, + @RequestBody @Valid body: SequencePlanRequest, + ) { + sequencerService.startSequencer(camera, body) } - @PutMapping("stop") - fun stopSequencer() { - sequencerService.stopSequencer() + @PutMapping("{camera}/stop") + fun stopSequencer(@DeviceOrEntityParam camera: Camera) { + sequencerService.stopSequencer(camera) } } diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerEvent.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerElapsed.kt similarity index 69% rename from api/src/main/kotlin/nebulosa/api/sequencer/SequencerEvent.kt rename to api/src/main/kotlin/nebulosa/api/sequencer/SequencerElapsed.kt index 8b00f412f..63d1ce089 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerEvent.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerElapsed.kt @@ -1,15 +1,15 @@ package nebulosa.api.sequencer -import nebulosa.api.cameras.CameraCaptureEvent +import nebulosa.api.cameras.CameraCaptureElapsed import nebulosa.api.messages.MessageEvent import java.time.Duration -data class SequencerEvent( +data class SequencerElapsed( val id: Int, val elapsedTime: Duration, val remainingTime: Duration, val progress: Double, - val capture: CameraCaptureEvent? = null, + val capture: CameraCaptureElapsed? = null, ) : MessageEvent { override val eventName = "SEQUENCER.ELAPSED" diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt index 6a4ea0ebd..d86288955 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt @@ -1,53 +1,40 @@ package nebulosa.api.sequencer -import io.reactivex.rxjava3.functions.Consumer -import nebulosa.api.messages.MessageEvent +import nebulosa.api.jobs.JobExecutor import nebulosa.api.messages.MessageService -import nebulosa.batch.processing.JobExecution import nebulosa.batch.processing.JobLauncher import nebulosa.guiding.Guider -import nebulosa.log.debug +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.indi.device.focuser.Focuser +import nebulosa.log.info import nebulosa.log.loggerFor import org.springframework.stereotype.Component -import java.util.* @Component class SequencerExecutor( private val messageService: MessageService, private val guider: Guider, - private val jobLauncher: JobLauncher, -) : Consumer { - - private val jobExecutions = LinkedList() + override val jobLauncher: JobLauncher, +) : JobExecutor() { @Synchronized - fun execute(request: SequencePlanRequest) { - check(!isRunning()) { "job is already running" } + fun execute( + camera: Camera, request: SequencePlanRequest, + wheel: FilterWheel? = null, focuser: Focuser? = null, + ) { + check(findJobExecutionWithAny(camera) == null) { "job is already running" } - LOG.debug { "starting sequencer. request=%s".format(request) } + LOG.info { "starting sequencer. camera=$camera, wheel=$wheel, focuser=$focuser, request=$request" } - val sequencerJob = SequencerJob(request, guider) - sequencerJob.subscribe(this) + val sequencerJob = SequencerJob(camera, request, guider, wheel, focuser) + sequencerJob.subscribe(messageService::sendMessage) sequencerJob.initialize() jobExecutions.add(jobLauncher.launch(sequencerJob)) } - fun findJobExecution(): JobExecution? { - return jobExecutions.lastOrNull { !it.isDone } - } - - fun isRunning(): Boolean { - return findJobExecution() != null - } - - @Synchronized - fun stop() { - val jobExecution = findJobExecution() ?: return - jobLauncher.stop(jobExecution) - } - - override fun accept(event: MessageEvent) { - messageService.sendMessage(event) + fun stop(camera: Camera) { + stopWithAny(camera) } companion object { diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt index ccdcbc357..b7a309be6 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt @@ -14,7 +14,10 @@ import nebulosa.batch.processing.ExecutionContext.Companion.getInt import nebulosa.batch.processing.delay.DelayStep import nebulosa.batch.processing.delay.DelayStepListener import nebulosa.guiding.Guider +import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.FrameType +import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.indi.device.focuser.Focuser import java.time.Duration import java.time.LocalDateTime @@ -23,8 +26,11 @@ import java.time.LocalDateTime // https://nighttime-imaging.eu/docs/master/site/sequencer/advanced/advanced/ data class SequencerJob( + @JvmField val camera: Camera, @JvmField val plan: SequencePlanRequest, @JvmField val guider: Guider, + @JvmField val wheel: FilterWheel? = null, + @JvmField val focuser: Focuser? = null, ) : SimpleJob(), PublishSubscribe, DelayStepListener { private val cameraCaptureEventHandler = CameraCaptureEventHandler(this) @@ -40,7 +46,7 @@ data class SequencerJob( val initialDelayStep = DelayStep(plan.initialDelay) initialDelayStep.registerDelayStepListener(this) - add(initialDelayStep) + register(initialDelayStep) val waitForSettleStep = WaitForSettleStep(guider) @@ -63,7 +69,7 @@ data class SequencerJob( if (plan.captureMode == SequenceCaptureMode.FULLY || usedEntries.size == 1) { for (i in usedEntries.indices) { val request = mapRequest(usedEntries[i]) - val cameraExposureStep = CameraExposureStep(request) + val cameraExposureStep = CameraExposureStep(camera, request) val delayStep = DelayStep(request.exposureDelay) delayStep.registerDelayStepListener(cameraExposureStep) val delayAndWaitForSettleStep = SimpleSplitStep(waitForSettleStep, delayStep) @@ -72,21 +78,21 @@ data class SequencerJob( val focusStep = request.focusStep() var estimatedCaptureTimeForEntry = Duration.ZERO - add(SequenceIdStep(plan.entries.indexOf(usedEntries[i]) + 1)) + register(SequenceIdStep(plan.entries.indexOf(usedEntries[i]) + 1)) repeat(request.exposureAmount) { if (i == 0 && it == 0) { - add(waitForSettleStep) + register(waitForSettleStep) } else { - add(delayAndWaitForSettleStep) + register(delayAndWaitForSettleStep) estimatedCaptureTime += request.exposureDelay estimatedCaptureTimeForEntry += request.exposureDelay } - wheelStep?.also(::add) - focusStep?.also(::add) - add(cameraExposureStep) - add(ditherStep) + wheelStep?.also(::register) + focusStep?.also(::register) + register(cameraExposureStep) + register(ditherStep) estimatedCaptureTime += request.exposureTime estimatedCaptureTimeForEntry += request.exposureTime @@ -101,7 +107,7 @@ data class SequencerJob( val count = IntArray(requests.size) val delaySteps = requests.map { DelayStep(it.exposureDelay) } val ditherSteps = requests.map { DitherAfterExposureStep(it.dither, guider) } - val cameraExposureSteps = requests.map { CameraExposureStep(it) } + val cameraExposureSteps = requests.map { CameraExposureStep(camera, it) } delaySteps.indices.forEach { delaySteps[it].registerDelayStepListener(cameraExposureSteps[it]) } val delayAndWaitForSettleSteps = requests.indices.map { SimpleSplitStep(waitForSettleStep, delaySteps[it]) } val wheelSteps = requests.map { it.wheelStep() } @@ -115,20 +121,20 @@ data class SequencerJob( val request = requests[i] if (count[i] < request.exposureAmount) { - add(sequenceIdSteps[i]) + register(sequenceIdSteps[i]) if (i == 0 && count[i] == 0) { - add(waitForSettleStep) + register(waitForSettleStep) } else { - add(delayAndWaitForSettleSteps[i]) + register(delayAndWaitForSettleSteps[i]) estimatedCaptureTime += delaySteps[i].duration estimatedCaptureTimeForEntry[i] += delaySteps[i].duration } - wheelSteps[i]?.also(::add) - focusSteps[i]?.also(::add) - add(cameraExposureSteps[i]) - add(ditherSteps[i]) + wheelSteps[i]?.also(::register) + focusSteps[i]?.also(::register) + register(cameraExposureSteps[i]) + register(ditherSteps[i]) estimatedCaptureTime += cameraExposureSteps[i].exposureTime estimatedCaptureTimeForEntry[i] += cameraExposureSteps[i].exposureTime @@ -152,11 +158,11 @@ data class SequencerJob( override fun afterJob(jobExecution: JobExecution) { val id = jobExecution.context.getInt(SequenceIdStep.ID) - super.onNext(SequencerEvent(id, estimatedCaptureTime, Duration.ZERO, 1.0)) + super.onNext(SequencerElapsed(id, estimatedCaptureTime, Duration.ZERO, 1.0)) } override fun onNext(event: MessageEvent) { - if (event is CameraCaptureEvent) { + if (event is CameraCaptureElapsed) { val context = event.jobExecution.context val id = context.getInt(SequenceIdStep.ID) @@ -172,7 +178,7 @@ data class SequencerJob( val progress = elapsedTime.toMillis() / estimatedCaptureTime.toMillis().toDouble() - super.onNext(SequencerEvent(id, elapsedTime, estimatedCaptureTime - elapsedTime, progress, event)) + super.onNext(SequencerElapsed(id, elapsedTime, estimatedCaptureTime - elapsedTime, progress, event)) } if (event is CameraExposureFinished) { @@ -186,7 +192,11 @@ data class SequencerJob( val elapsedTime = step.duration - remainingTime val progress = elapsedTime.toMillis() / estimatedCaptureTime.toMillis().toDouble() - super.onNext(SequencerEvent(0, elapsedTime, estimatedCaptureTime - elapsedTime, progress)) + super.onNext(SequencerElapsed(0, elapsedTime, estimatedCaptureTime - elapsedTime, progress)) + } + + override fun contains(data: Any): Boolean { + return data === camera || super.contains(data) } private data class SequenceIdStep(private val id: Int) : Step { diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt index ae95aad24..18ea4f3e6 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt @@ -1,5 +1,6 @@ package nebulosa.api.sequencer +import nebulosa.indi.device.camera.Camera import org.springframework.stereotype.Service import java.nio.file.Path import kotlin.io.path.exists @@ -12,18 +13,16 @@ class SequencerService( ) { @Synchronized - fun startSequencer(request: SequencePlanRequest) { - if (sequencerExecutor.isRunning()) return - + fun startSequencer(camera: Camera, request: SequencePlanRequest) { val savePath = request.savePath ?.takeIf { "$it".isNotBlank() && it.exists() && it.isDirectory() } ?: Path.of("$sequencesPath", (System.currentTimeMillis() / 1000).toString()) sequencerExecutor - .execute(request.copy(savePath = savePath)) + .execute(camera, request.copy(savePath = savePath)) } - fun stopSequencer() { - sequencerExecutor.stop() + fun stopSequencer(camera: Camera) { + sequencerExecutor.stop(camera) } } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardElapsed.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardElapsed.kt index 948fdd336..af16978c0 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardElapsed.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardElapsed.kt @@ -1,12 +1,12 @@ package nebulosa.api.wizard.flat -import nebulosa.api.cameras.CameraCaptureEvent +import nebulosa.api.cameras.CameraCaptureElapsed import nebulosa.api.messages.MessageEvent import java.time.Duration data class FlatWizardElapsed( val exposureTime: Duration, - val capture: CameraCaptureEvent, + val capture: CameraCaptureElapsed, ) : MessageEvent { override val eventName = "FLAT_WIZARD.ELAPSED" diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt index d0db460ec..daee1c892 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt @@ -1,36 +1,29 @@ package nebulosa.api.wizard.flat -import io.reactivex.rxjava3.functions.Consumer -import nebulosa.api.messages.MessageEvent +import nebulosa.api.jobs.JobExecutor import nebulosa.api.messages.MessageService import nebulosa.batch.processing.JobExecution import nebulosa.batch.processing.JobLauncher import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.filterwheel.FilterWheel -import nebulosa.log.debug +import nebulosa.log.info import nebulosa.log.loggerFor import org.springframework.stereotype.Component -import java.util.* @Component class FlatWizardExecutor( private val messageService: MessageService, - private val jobLauncher: JobLauncher, -) : Consumer { - - private val jobExecutions = LinkedList() - - fun execute(request: FlatWizardRequest) { - val camera = requireNotNull(request.captureRequest.camera) + override val jobLauncher: JobLauncher, +) : JobExecutor() { + fun execute(camera: Camera, request: FlatWizardRequest) { check(camera.connected) { "camera is not connected" } - check(!isCapturing(camera)) { "job is already running for camera: [${camera.name}]" } + check(findJobExecution(camera) == null) { "job is already running for camera: [${camera.name}]" } - LOG.debug { "starting flat wizard capture. request=$request" } + LOG.info { "starting flat wizard capture. camera=$camera, request=$request" } - val flatWizardJob = FlatWizardJob(request) - flatWizardJob.subscribe(this) - jobExecutions.add(jobLauncher.launch(flatWizardJob)) + val flatWizardJob = FlatWizardJob(camera, request) + flatWizardJob.subscribe(messageService::sendMessage) + register(jobLauncher.launch(flatWizardJob)) } fun findJobExecution(camera: Camera): JobExecution? { @@ -46,18 +39,8 @@ class FlatWizardExecutor( return null } - @Synchronized fun stop(camera: Camera) { - val jobExecution = findJobExecution(camera) ?: return - jobLauncher.stop(jobExecution) - } - - fun isCapturing(camera: Camera, wheel: FilterWheel? = null): Boolean { - return findJobExecution(camera) != null - } - - override fun accept(event: MessageEvent) { - messageService.sendMessage(event) + stopWithAny(camera) } companion object { diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt index b6b04f472..fe34f6296 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt @@ -1,29 +1,32 @@ package nebulosa.api.wizard.flat import io.reactivex.rxjava3.subjects.PublishSubject -import nebulosa.api.cameras.CameraCaptureEvent +import nebulosa.api.cameras.CameraCaptureElapsed import nebulosa.api.cameras.CameraCaptureEventHandler import nebulosa.api.cameras.CameraExposureFinished import nebulosa.api.messages.MessageEvent import nebulosa.batch.processing.PublishSubscribe import nebulosa.batch.processing.SimpleJob +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.filterwheel.FilterWheel import java.nio.file.Path import java.time.Duration -data class FlatWizardJob(@JvmField val request: FlatWizardRequest) : SimpleJob(), PublishSubscribe, FlatWizardExecutionListener { - - @JvmField val camera = request.captureRequest.camera - @JvmField val wheel = request.captureRequest.wheel +data class FlatWizardJob( + @JvmField val camera: Camera, + @JvmField val request: FlatWizardRequest, + @JvmField val wheel: FilterWheel? = null, +) : SimpleJob(), PublishSubscribe, FlatWizardExecutionListener { private val cameraCaptureEventHandler = CameraCaptureEventHandler(this) - private val step = FlatWizardStep(request) + private val step = FlatWizardStep(camera, request) override val subject = PublishSubject.create() init { step.registerCameraCaptureListener(cameraCaptureEventHandler) step.registerFlatWizardExecutionListener(this) - add(step) + register(step) } override fun onFlatCaptured(step: FlatWizardStep, savedPath: Path, duration: Duration) { @@ -35,12 +38,13 @@ data class FlatWizardJob(@JvmField val request: FlatWizardRequest) : SimpleJob() } override fun onNext(event: MessageEvent) { - if (event is CameraCaptureEvent) { + if (event is CameraCaptureElapsed) { super.onNext(FlatWizardElapsed(step.exposureTime, event)) - } - if (event is CameraExposureFinished) { - super.onNext(event) + // Notify Camera window to retrieve new image. + if (event is CameraExposureFinished) { + super.onNext(event) + } } } } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardService.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardService.kt index ec0ec35be..a175a48d1 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardService.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardService.kt @@ -18,7 +18,8 @@ class FlatWizardService( ?.takeIf { "$it".isNotBlank() && it.exists() && it.isDirectory() } ?: Path.of("$capturesPath", camera.name, "FLAT") - flatWizardExecutor.execute(request.copy(captureRequest = request.captureRequest.copy(camera = camera, savePath = savePath))) + flatWizardExecutor + .execute(camera, request.copy(captureRequest = request.captureRequest.copy(savePath = savePath))) } @Synchronized diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt index 2f9215ba0..fbfae0012 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt @@ -10,6 +10,7 @@ import nebulosa.batch.processing.StepResult import nebulosa.fits.Fits import nebulosa.imaging.Image import nebulosa.imaging.algorithms.computation.Statistics +import nebulosa.indi.device.camera.Camera import nebulosa.io.transferAndClose import org.slf4j.LoggerFactory import java.time.Duration @@ -19,6 +20,7 @@ import kotlin.io.path.inputStream import kotlin.io.path.outputStream data class FlatWizardStep( + @JvmField val camera: Camera, @JvmField val request: FlatWizardRequest, ) : Step { @@ -68,6 +70,7 @@ data class FlatWizardStep( exposureTime = (exposureMax + exposureMin).dividedBy(2L) val cameraExposureStep = CameraExposureStep( + camera, request.captureRequest.copy( exposureTime = exposureTime, exposureAmount = 1, autoSave = false, autoSubFolderMode = AutoSubFolderMode.OFF, @@ -91,7 +94,7 @@ data class FlatWizardStep( LOG.info("flat frame captured. duration={}, statistics={}", exposureTime, statistics) if (statistics.mean in meanRange) { - val path = request.captureRequest.makeSavePath(true) + val path = request.captureRequest.makeSavePath(camera, true) savedPath.inputStream().transferAndClose(path.outputStream()) savedPath.deleteIfExists() LOG.info("Found an optimal exposure time. exposure={}, path={}", exposureTime, path) diff --git a/desktop/src/app/alignment/alignment.component.ts b/desktop/src/app/alignment/alignment.component.ts index b46ceddd5..7a2c04920 100644 --- a/desktop/src/app/alignment/alignment.component.ts +++ b/desktop/src/app/alignment/alignment.component.ts @@ -3,7 +3,7 @@ import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' import { ElectronService } from '../../shared/services/electron.service' import { LocalStorageService } from '../../shared/services/local-storage.service' -import { DARVState, Hemisphere } from '../../shared/types/alignment.types' +import { DARVStart, DARVState, Hemisphere } from '../../shared/types/alignment.types' import { Camera, CameraPreference, CameraStartCapture, cameraPreferenceKey } from '../../shared/types/camera.types' import { GuideDirection, GuideOutput } from '../../shared/types/guider.types' import { AppComponent } from '../app.component' @@ -167,11 +167,11 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } async darvStart(direction: GuideDirection = 'EAST') { - // TODO: Horizonte leste e oeste tem um impacto no "reversed"? const reversed = this.darvHemisphere === 'SOUTHERN' await this.openCameraImage() const capture = this.makeCameraStartCapture(this.camera!) - await this.api.darvStart(this.camera!, this.guideOutput!, this.darvDrift, this.darvInitialPause, direction, reversed, capture) + const data: DARVStart = { capture, exposureTime: this.darvDrift, initialPause: this.darvInitialPause, direction, reversed } + await this.api.darvStart(this.camera!, this.guideOutput!, data) } darvStop() { diff --git a/desktop/src/app/camera/camera.component.ts b/desktop/src/app/camera/camera.component.ts index 18af649d2..e8588f587 100644 --- a/desktop/src/app/camera/camera.component.ts +++ b/desktop/src/app/camera/camera.component.ts @@ -177,7 +177,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { if (data) { this.mode = data.mode Object.assign(this.request, data.request) - await this.cameraChanged(this.request.camera) + await this.cameraChanged(data.camera) this.normalizeExposureTimeAndUnit(this.request.exposureTime) this.loadDefaultsForMode(data.mode) } @@ -281,7 +281,6 @@ export class CameraComponent implements AfterContentInit, OnDestroy { return { ...this.request, - camera: this.camera, x, y, width, height, exposureTime, exposureAmount, savePath, @@ -427,8 +426,8 @@ export class CameraComponent implements AfterContentInit, OnDestroy { } } - static async showAsDialog(window: BrowserWindowService, mode: CameraDialogMode, request: CameraStartCapture) { - const result = await window.openCameraDialog({ data: { mode, request } }) + static async showAsDialog(window: BrowserWindowService, mode: CameraDialogMode, camera: Camera, request: CameraStartCapture) { + const result = await window.openCameraDialog({ data: { mode, camera, request } }) if (result) { Object.assign(request, result) diff --git a/desktop/src/app/filterwheel/filterwheel.component.ts b/desktop/src/app/filterwheel/filterwheel.component.ts index 145597328..69f0305f0 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.ts +++ b/desktop/src/app/filterwheel/filterwheel.component.ts @@ -94,7 +94,7 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { const request = decodedData as WheelDialogInput Object.assign(this.request, request.request) this.mode = request.mode - this.wheelChanged(this.request.wheel) + this.wheelChanged(request.wheel) } else { this.wheelChanged(decodedData) } @@ -210,7 +210,6 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { private makeCameraStartCapture(): CameraStartCapture { return { ...this.request, - wheel: this.wheel, filterPosition: this.filter?.position ?? 0, } } @@ -219,8 +218,8 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { this.app.close(this.makeCameraStartCapture()) } - static async showAsDialog(window: BrowserWindowService, mode: WheelDialogMode, request: CameraStartCapture) { - const result = await window.openWheelDialog({ data: { mode, request } }) + static async showAsDialog(window: BrowserWindowService, mode: WheelDialogMode, wheel: FilterWheel, request: CameraStartCapture) { + const result = await window.openWheelDialog({ data: { mode, wheel, request } }) if (result) { Object.assign(request, result) diff --git a/desktop/src/app/flat-wizard/flat-wizard.component.ts b/desktop/src/app/flat-wizard/flat-wizard.component.ts index 5b2ddec4a..0c3922c28 100644 --- a/desktop/src/app/flat-wizard/flat-wizard.component.ts +++ b/desktop/src/app/flat-wizard/flat-wizard.component.ts @@ -99,7 +99,7 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { ngOnDestroy() { } async showCameraDialog() { - CameraComponent.showAsDialog(this.browserWindow, 'FLAT_WIZARD', this.request.captureRequest) + CameraComponent.showAsDialog(this.browserWindow, 'FLAT_WIZARD', this.camera!, this.request.captureRequest) } cameraChanged() { @@ -118,8 +118,6 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { if (camera) { const request = this.request.captureRequest - request.camera = camera - if (camera.connected) { if (camera.maxX > 1) request.x = Math.max(camera.minX, Math.min(request.x, camera.maxX)) if (camera.maxY > 1) request.y = Math.max(camera.minY, Math.min(request.y, camera.maxY)) diff --git a/desktop/src/app/sequencer/sequencer.component.html b/desktop/src/app/sequencer/sequencer.component.html index 3eca70a12..13537abcc 100644 --- a/desktop/src/app/sequencer/sequencer.component.html +++ b/desktop/src/app/sequencer/sequencer.component.html @@ -138,7 +138,7 @@ @@ -147,14 +147,14 @@ + emptyMessage="No filter wheel found" (ngModelChange)="savePlan()" />
+ styleClass="p-inputtext-sm border-0" emptyMessage="No focuser found" (ngModelChange)="savePlan()" />
@@ -189,14 +189,14 @@ #{{ i + 1 }}
- - -
diff --git a/desktop/src/app/sequencer/sequencer.component.ts b/desktop/src/app/sequencer/sequencer.component.ts index 3b3aa011b..5b83ad52b 100644 --- a/desktop/src/app/sequencer/sequencer.component.ts +++ b/desktop/src/app/sequencer/sequencer.component.ts @@ -7,7 +7,7 @@ import { BrowserWindowService } from '../../shared/services/browser-window.servi import { ElectronService } from '../../shared/services/electron.service' import { LocalStorageService } from '../../shared/services/local-storage.service' import { JsonFile } from '../../shared/types/app.types' -import { Camera, CameraCaptureEvent, CameraStartCapture } from '../../shared/types/camera.types' +import { Camera, CameraCaptureElapsed, CameraStartCapture } from '../../shared/types/camera.types' import { Focuser } from '../../shared/types/focuser.types' import { EMPTY_SEQUENCE_PLAN, SequenceCaptureMode, SequencePlan, SequencerEvent } from '../../shared/types/sequencer.types' import { FilterWheel } from '../../shared/types/wheel.types' @@ -36,7 +36,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { readonly captureModes: SequenceCaptureMode[] = ['FULLY', 'INTERLEAVED'] readonly plan = Object.assign({}, EMPTY_SEQUENCE_PLAN) - readonly sequenceEvents: CameraCaptureEvent[] = [] + readonly sequenceEvents: CameraCaptureElapsed[] = [] event?: SequencerEvent running = false @@ -45,7 +45,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { private readonly cameraExposures!: QueryList get canStart() { - return !this.plan.entries.find(e => e.enabled && !e.camera?.connected) + return !!this.camera && this.camera.connected && !!this.plan.entries.find(e => e.enabled) } get savedPath() { @@ -127,7 +127,6 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { if (camera) { Object.assign(camera, event.device) - this.updateEntriesFromCamera(camera) } }) }) @@ -138,7 +137,6 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { if (wheel) { Object.assign(wheel, event.device) - this.updateEntriesFromWheel(wheel) } }) }) @@ -149,7 +147,6 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { if (focuser) { Object.assign(focuser, event.device) - this.updateEntriesFromFocuser(focuser) } }) }) @@ -199,7 +196,6 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { this.plan.entries.push({ enabled: true, - camera, exposureTime: 1000000, exposureAmount: 1, exposureDelay: 0, @@ -215,8 +211,6 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { frameFormat: camera?.frameFormats[0], autoSave: true, autoSubFolderMode: 'OFF', - wheel, - focuser, }) this.savePlan() @@ -263,8 +257,6 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { } updateEntryFromCamera(entry: CameraStartCapture, camera?: Camera) { - entry.camera = camera - if (camera) { if (camera.connected) { if (camera.maxX > 1) entry.x = Math.max(camera.minX, Math.min(entry.x, camera.maxX)) @@ -284,56 +276,14 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { } } - updateEntriesFromCamera(camera?: Camera) { - for (const entry of this.plan.entries) { - this.updateEntryFromCamera(entry, camera) - } - } - - updateEntryFromWheel(entry: CameraStartCapture, wheel?: FilterWheel) { - entry.wheel = wheel - - if (wheel) { - if (wheel.connected) { - this.savePlan() - } - } - } - - updateEntriesFromWheel(wheel?: FilterWheel) { - for (const entry of this.plan.entries) { - this.updateEntryFromWheel(entry, wheel) - } - } - - updateEntryFromFocuser(entry: CameraStartCapture, focuser?: Focuser) { - entry.focuser = focuser - - if (focuser) { - if (focuser.connected) { - this.savePlan() - } - } - } - - updateEntriesFromFocuser(focuser?: Focuser) { - for (const entry of this.plan.entries) { - this.updateEntryFromFocuser(entry, focuser) - } - } - private loadPlan(plan?: SequencePlan) { plan ??= this.storage.get(SEQUENCER_PLAN_KEY, this.plan) Object.assign(this.plan, plan) - this.camera = this.cameras.find(e => e.name === this.plan.entries[0]?.camera?.name) ?? this.cameras[0] - this.focuser = this.focusers.find(e => e.name === this.plan.entries[0]?.focuser?.name) ?? this.focusers[0] - this.wheel = this.wheels.find(e => e.name === this.plan.entries[0]?.wheel?.name) ?? this.wheels[0] - - this.updateEntriesFromCamera(this.camera) - this.updateEntriesFromWheel(this.wheel) - this.updateEntriesFromFocuser(this.focuser) + this.camera = this.cameras.find(e => e.name === this.plan.camera?.name) ?? this.cameras[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] return plan.entries.length } @@ -364,18 +314,21 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { } async showCameraDialog(entry: CameraStartCapture) { - if (await CameraComponent.showAsDialog(this.browserWindow, 'SEQUENCER', entry)) { + if (await CameraComponent.showAsDialog(this.browserWindow, 'SEQUENCER', this.camera!, entry)) { this.savePlan() } } async showWheelDialog(entry: CameraStartCapture) { - if (await FilterWheelComponent.showAsDialog(this.browserWindow, 'SEQUENCER', entry)) { + if (await FilterWheelComponent.showAsDialog(this.browserWindow, 'SEQUENCER', this.wheel!, entry)) { this.savePlan() } } savePlan() { + this.plan.camera = this.camera + this.plan.wheel = this.wheel + this.plan.focuser = this.focuser this.storage.set(SEQUENCER_PLAN_KEY, this.plan) this.savedPathWasModified = !!this.savedPath } @@ -389,7 +342,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { } async start() { - for (let i = 0; i < this.plan.entries.length; i++) { + for (let i = 0; i < this.cameraExposures.length; i++) { this.cameraExposures.get(i)?.reset() } @@ -397,10 +350,10 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { await this.browserWindow.openCameraImage(this.camera!) - this.api.sequencerStart(this.plan) + this.api.sequencerStart(this.camera!, this.plan) } stop() { - this.api.sequencerStop() + this.api.sequencerStop(this.camera!) } } 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 f151d5545..5210e7a20 100644 --- a/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts +++ b/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from '@angular/core' -import { CameraCaptureEvent, CameraCaptureState, EMPTY_CAMERA_CAPTURE_INFO, EMPTY_CAMERA_EXPOSURE_INFO, EMPTY_CAMERA_WAIT_INFO } from '../../types/camera.types' +import { CameraCaptureElapsed, CameraCaptureState, EMPTY_CAMERA_CAPTURE_INFO, EMPTY_CAMERA_EXPOSURE_INFO, EMPTY_CAMERA_WAIT_INFO } from '../../types/camera.types' @Component({ selector: 'neb-camera-exposure', @@ -20,7 +20,7 @@ export class CameraExposureComponent { @Input() readonly wait = Object.assign({}, EMPTY_CAMERA_WAIT_INFO) - handleCameraCaptureEvent(event: CameraCaptureEvent) { + handleCameraCaptureEvent(event: CameraCaptureElapsed) { this.capture.elapsedTime = event.captureElapsedTime this.capture.remainingTime = event.captureRemainingTime this.capture.progress = event.captureProgress diff --git a/desktop/src/shared/services/api.service.ts b/desktop/src/shared/services/api.service.ts index 52ca0f9f4..9f2c01efc 100644 --- a/desktop/src/shared/services/api.service.ts +++ b/desktop/src/shared/services/api.service.ts @@ -531,9 +531,7 @@ export class ApiService { // DARV - darvStart(camera: Camera, guideOutput: GuideOutput, - exposureTime: number, initialPause: number, direction: GuideDirection, reversed: boolean = false, capture?: CameraStartCapture) { - const data: DARVStart = { capture, exposureTime, initialPause, direction, reversed } + darvStart(camera: Camera, guideOutput: GuideOutput, data: DARVStart) { return this.http.put(`polar-alignment/darv/${camera.name}/${guideOutput.name}/start`, data) } @@ -543,12 +541,13 @@ export class ApiService { // SEQUENCER - sequencerStart(plan: SequencePlan) { - return this.http.put(`sequencer/start`, plan) + sequencerStart(camera: Camera, plan: SequencePlan) { + const body: SequencePlan = { ...plan, camera: undefined, wheel: undefined, focuser: undefined } + return this.http.put(`sequencer/${camera.name}/start`, body) } - sequencerStop() { - return this.http.put(`sequencer/stop`) + sequencerStop(camera: Camera) { + return this.http.put(`sequencer/${camera.name}/stop`) } // FLAT WIZARD diff --git a/desktop/src/shared/services/browser-window.service.ts b/desktop/src/shared/services/browser-window.service.ts index 6767e9e98..c656ff548 100644 --- a/desktop/src/shared/services/browser-window.service.ts +++ b/desktop/src/shared/services/browser-window.service.ts @@ -37,7 +37,7 @@ export class BrowserWindowService { openCameraDialog(options: OpenWindowOptionsWithData) { Object.assign(options, { icon: 'camera', width: 400, height: 424 }) - return this.openModal({ ...options, id: `camera.${options.data.request.camera!.name}.modal`, path: 'camera' }) + return this.openModal({ ...options, id: `camera.${options.data.camera.name}.modal`, path: 'camera' }) } openFocuser(options: OpenWindowOptionsWithData) { @@ -52,7 +52,7 @@ export class BrowserWindowService { openWheelDialog(options: OpenWindowOptionsWithData) { Object.assign(options, { icon: 'filter-wheel', width: 300, height: 217 }) - return this.openModal({ ...options, id: `wheel.${options.data.request.camera!.name}.modal`, path: 'wheel' }) + return this.openModal({ ...options, id: `wheel.${options.data.wheel.name}.modal`, path: 'wheel' }) } openGuider(options: OpenWindowOptions = {}) { diff --git a/desktop/src/shared/services/electron.service.ts b/desktop/src/shared/services/electron.service.ts index 5bf1758f5..4723b2c8e 100644 --- a/desktop/src/shared/services/electron.service.ts +++ b/desktop/src/shared/services/electron.service.ts @@ -7,11 +7,11 @@ import { Injectable } from '@angular/core' import * as childProcess from 'child_process' import { ipcRenderer, webFrame } from 'electron' import * as fs from 'fs' -import { DARVEvent } from '../types/alignment.types' +import { DARVElapsed } from '../types/alignment.types' import { ApiEventType, DeviceMessageEvent } from '../types/api.types' import { CloseWindow, InternalEventType, JsonFile, OpenDirectory, OpenFile, SaveJson } from '../types/app.types' import { Location } from '../types/atlas.types' -import { Camera, CameraCaptureEvent } from '../types/camera.types' +import { Camera, CameraCaptureElapsed } from '../types/camera.types' import { INDIMessageEvent } from '../types/device.types' import { FlatWizardEvent } from '../types/flat-wizard.types' import { Focuser } from '../types/focuser.types' @@ -28,7 +28,7 @@ type EventMappedType = { 'CAMERA.UPDATED': DeviceMessageEvent 'CAMERA.ATTACHED': DeviceMessageEvent 'CAMERA.DETACHED': DeviceMessageEvent - 'CAMERA.CAPTURE_ELAPSED': CameraCaptureEvent + 'CAMERA.CAPTURE_ELAPSED': CameraCaptureElapsed 'MOUNT.UPDATED': DeviceMessageEvent 'MOUNT.ATTACHED': DeviceMessageEvent 'MOUNT.DETACHED': DeviceMessageEvent @@ -46,7 +46,7 @@ type EventMappedType = { 'GUIDER.UPDATED': GuiderMessageEvent 'GUIDER.STEPPED': GuiderMessageEvent 'GUIDER.MESSAGE_RECEIVED': GuiderMessageEvent - 'DARV_ALIGNMENT.ELAPSED': DARVEvent + 'DARV_ALIGNMENT.ELAPSED': DARVElapsed 'DATA.CHANGED': any 'LOCATION.CHANGED': Location 'SEQUENCER.ELAPSED': SequencerEvent diff --git a/desktop/src/shared/types/alignment.types.ts b/desktop/src/shared/types/alignment.types.ts index 1412fdc87..e62741ffd 100644 --- a/desktop/src/shared/types/alignment.types.ts +++ b/desktop/src/shared/types/alignment.types.ts @@ -13,7 +13,7 @@ export interface DARVStart { reversed: boolean } -export interface DARVEvent extends MessageEvent { +export interface DARVElapsed extends MessageEvent { camera: Camera guideOutput: GuideOutput remainingTime: number diff --git a/desktop/src/shared/types/camera.types.ts b/desktop/src/shared/types/camera.types.ts index 0d1a926d0..49e261286 100644 --- a/desktop/src/shared/types/camera.types.ts +++ b/desktop/src/shared/types/camera.types.ts @@ -1,9 +1,7 @@ import { MessageEvent } from './api.types' import { Thermometer } from './auxiliary.types' import { PropertyState } from './device.types' -import { Focuser } from './focuser.types' import { GuideOutput } from './guider.types' -import { FilterWheel } from './wheel.types' export type CameraDialogMode = 'CAPTURE' | 'SEQUENCER' | 'FLAT_WIZARD' @@ -133,7 +131,6 @@ export interface Dither { export interface CameraStartCapture { enabled?: boolean - camera?: Camera exposureTime: number exposureAmount: number exposureDelay: number @@ -151,10 +148,8 @@ export interface CameraStartCapture { savePath?: string autoSubFolderMode: AutoSubFolderMode dither?: Dither - wheel?: FilterWheel filterPosition?: number shutterPosition?: number - focuser?: Focuser focusOffset?: number } @@ -181,7 +176,7 @@ export const EMPTY_CAMERA_START_CAPTURE: CameraStartCapture = { } } -export interface CameraCaptureEvent extends MessageEvent { +export interface CameraCaptureElapsed extends MessageEvent { camera: Camera exposureAmount: number exposureCount: number @@ -201,6 +196,7 @@ export type CameraCaptureState = 'IDLE' | 'CAPTURE_STARTED' | 'EXPOSURE_STARTED' export interface CameraDialogInput { mode: CameraDialogMode + camera: Camera request: CameraStartCapture } diff --git a/desktop/src/shared/types/flat-wizard.types.ts b/desktop/src/shared/types/flat-wizard.types.ts index 0a8e9e10a..8e28965be 100644 --- a/desktop/src/shared/types/flat-wizard.types.ts +++ b/desktop/src/shared/types/flat-wizard.types.ts @@ -1,4 +1,4 @@ -import { CameraCaptureEvent, CameraStartCapture } from './camera.types' +import { CameraCaptureElapsed, CameraStartCapture } from './camera.types' export interface FlatWizardRequest { captureRequest: CameraStartCapture @@ -10,6 +10,6 @@ export interface FlatWizardRequest { export interface FlatWizardEvent { exposureTime: number - capture?: CameraCaptureEvent + capture?: CameraCaptureElapsed savedPath?: string } diff --git a/desktop/src/shared/types/sequencer.types.ts b/desktop/src/shared/types/sequencer.types.ts index eba4eeb9a..6d2f75b04 100644 --- a/desktop/src/shared/types/sequencer.types.ts +++ b/desktop/src/shared/types/sequencer.types.ts @@ -1,4 +1,6 @@ -import { AutoSubFolderMode, CameraCaptureEvent, CameraStartCapture, Dither } from './camera.types' +import { AutoSubFolderMode, Camera, CameraCaptureElapsed, CameraStartCapture, Dither } from './camera.types' +import { Focuser } from './focuser.types' +import { FilterWheel } from './wheel.types' export type SequenceCaptureMode = 'FULLY' | 'INTERLEAVED' @@ -24,6 +26,9 @@ export interface SequencePlan { entries: CameraStartCapture[] dither: Dither autoFocus: AutoFocusAfterConditions + camera?: Camera + wheel?: FilterWheel + focuser?: Focuser } export const EMPTY_SEQUENCE_PLAN: SequencePlan = { @@ -57,5 +62,5 @@ export interface SequencerEvent extends MessageEvent { elapsedTime: number remainingTime: number progress: number - capture?: CameraCaptureEvent + capture?: CameraCaptureElapsed } diff --git a/desktop/src/shared/types/wheel.types.ts b/desktop/src/shared/types/wheel.types.ts index 40aa5c1dd..723cd074d 100644 --- a/desktop/src/shared/types/wheel.types.ts +++ b/desktop/src/shared/types/wheel.types.ts @@ -19,6 +19,7 @@ export const EMPTY_WHEEL: FilterWheel = { export interface WheelDialogInput { mode: WheelDialogMode + wheel: FilterWheel request: CameraStartCapture } diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Job.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Job.kt index 60c35e8a4..79a3243bd 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Job.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Job.kt @@ -2,6 +2,8 @@ package nebulosa.batch.processing interface Job : JobExecutionListener, Stoppable { + val id: String + fun hasNext(jobExecution: JobExecution): Boolean fun next(jobExecution: JobExecution): Step @@ -9,4 +11,6 @@ interface Job : JobExecutionListener, Stoppable { override fun beforeJob(jobExecution: JobExecution) = Unit override fun afterJob(jobExecution: JobExecution) = Unit + + operator fun contains(data: Any): Boolean } diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleJob.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleJob.kt index 9b062638a..cc6e4b3a5 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleJob.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleJob.kt @@ -1,36 +1,65 @@ package nebulosa.batch.processing -abstract class SimpleJob : Job, ArrayList { +import java.util.* - constructor(initialCapacity: Int = 4) : super(initialCapacity) +abstract class SimpleJob : Job, Iterable { - constructor(steps: Collection) : super(steps) + private val steps = ArrayList() - constructor(vararg steps: Step) : this(steps.toList()) + constructor(steps: Collection) { + this.steps.addAll(steps) + } + + constructor(vararg steps: Step) { + steps.forEach(this.steps::add) + } + + override val id = UUID.randomUUID().toString() @Volatile private var position = 0 - @Volatile private var stopped = false + @Volatile private var end = false + + protected fun register(step: Step): Boolean { + return steps.add(step) + } + + protected fun unregister(step: Step): Boolean { + return steps.remove(step) + } + + protected fun clear() { + return steps.clear() + } override fun hasNext(jobExecution: JobExecution): Boolean { - return !stopped && position < size + return !end && position < steps.size } override fun next(jobExecution: JobExecution): Step { - return this[position++] + check(!end) { "this job is ended" } + return steps[position++] } override fun stop(mayInterruptIfRunning: Boolean) { - if (stopped) return + if (end) return - stopped = true + end = true - if (position in 1..size) { - this[position - 1].stop(mayInterruptIfRunning) + if (position in 1..steps.size) { + steps[position - 1].stop(mayInterruptIfRunning) } } fun reset() { - stopped = false + end = false position = 0 } + + override fun iterator(): Iterator { + return steps.iterator() + } + + override fun contains(data: Any): Boolean { + return data is Step && data in steps + } } From 709941c3c0a2d4f8d22f21f6f9c9a3554983c9a0 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 3 Feb 2024 15:29:11 -0300 Subject: [PATCH 05/87] [desktop]: Plate solver has settings per type --- desktop/src/app/image/image.component.ts | 16 ++++----- .../src/app/settings/settings.component.html | 35 ++++++++++--------- .../src/app/settings/settings.component.ts | 25 ++++++++++--- 3 files changed, 47 insertions(+), 29 deletions(-) diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 167affeb8..71a040eff 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -19,10 +19,10 @@ import { Angle, AstronomicalObject, DeepSkyObject, EquatorialCoordinateJ2000, St import { Camera } from '../../shared/types/camera.types' import { DetectedStar, EMPTY_IMAGE_SOLVED, FITSHeaderItem, ImageAnnotation, ImageChannel, ImageInfo, ImageSource, ImageStatisticsBitOption, SCNRProtectionMethod, SCNR_PROTECTION_METHODS } from '../../shared/types/image.types' import { Mount } from '../../shared/types/mount.types' -import { EMPTY_PLATE_SOLVER_OPTIONS, PlateSolverType } from '../../shared/types/settings.types' +import { PlateSolverType } from '../../shared/types/settings.types' import { CoordinateInterpolator, InterpolatedCoordinate } from '../../shared/utils/coordinate-interpolation' import { AppComponent } from '../app.component' -import { SETTINGS_PLATE_SOLVER_KEY } from '../settings/settings.component' +import { SettingsComponent } from '../settings/settings.component' export function imagePreferenceKey(camera?: Camera) { return camera ? `image.${camera.name}` : 'image' @@ -30,6 +30,7 @@ export function imagePreferenceKey(camera?: Camera) { export interface ImagePreference { solverRadius?: number + solverType?: PlateSolverType } export interface ImageData { @@ -94,7 +95,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { solverRadius = 4 readonly solvedData = Object.assign({}, EMPTY_IMAGE_SOLVED) readonly solverTypes: PlateSolverType[] = ['ASTAP', 'ASTROMETRY_NET_ONLINE'] - solverType: PlateSolverType + solverType = this.solverTypes[0] crossHair = false annotations: ImageAnnotation[] = [] @@ -417,8 +418,6 @@ export class ImageComponent implements AfterViewInit, OnDestroy { event.preventDefault() } }, true) - - this.solverType = this.storage.get(SETTINGS_PLATE_SOLVER_KEY, EMPTY_PLATE_SOLVER_OPTIONS).type } ngAfterViewInit() { @@ -717,8 +716,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.solving = true try { - const options = this.storage.get(SETTINGS_PLATE_SOLVER_KEY, EMPTY_PLATE_SOLVER_OPTIONS) - options.type = this.solverType + const options = SettingsComponent.getPlateSolverOptions(this.storage, this.solverType) Object.assign(this.solvedData, await this.api.solveImage(options, this.imageData.path!, this.solverBlind, @@ -785,11 +783,13 @@ export class ImageComponent implements AfterViewInit, OnDestroy { private loadPreference(camera?: Camera) { const preference = this.storage.get(imagePreferenceKey(camera), {}) this.solverRadius = preference.solverRadius ?? this.solverRadius + this.solverType = preference.solverType ?? this.solverTypes[0] } private savePreference() { const preference: ImagePreference = { - solverRadius: this.solverRadius + solverRadius: this.solverRadius, + solverType: this.solverType } this.storage.set(imagePreferenceKey(this.imageData.camera), preference) diff --git a/desktop/src/app/settings/settings.component.html b/desktop/src/app/settings/settings.component.html index d9f7f1737..3e921ed58 100644 --- a/desktop/src/app/settings/settings.component.html +++ b/desktop/src/app/settings/settings.component.html @@ -29,16 +29,16 @@
- +
-
+
- + @@ -46,31 +46,34 @@
-
+
- +
-
+
- +
-
+
- +
- +
diff --git a/desktop/src/app/settings/settings.component.ts b/desktop/src/app/settings/settings.component.ts index d26ce46b0..74a72eee5 100644 --- a/desktop/src/app/settings/settings.component.ts +++ b/desktop/src/app/settings/settings.component.ts @@ -6,6 +6,7 @@ import { ApiService } from '../../shared/services/api.service' import { ElectronService } from '../../shared/services/electron.service' import { LocalStorageService } from '../../shared/services/local-storage.service' import { PrimeService } from '../../shared/services/prime.service' +import { StorageService } from '../../shared/services/storage.service' import { EMPTY_LOCATION, Location } from '../../shared/types/atlas.types' import { EMPTY_PLATE_SOLVER_OPTIONS, PlateSolverOptions, PlateSolverType } from '../../shared/types/settings.types' import { AppComponent } from '../app.component' @@ -25,7 +26,8 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { location = Object.assign({}, EMPTY_LOCATION) readonly plateSolverTypes: PlateSolverType[] = ['ASTAP', /*'ASTROMETRY_NET',*/ 'ASTROMETRY_NET_ONLINE'] - readonly plateSolver: PlateSolverOptions + plateSolverType = this.plateSolverTypes[0] + readonly plateSolvers = new Map() readonly items: MenuItem[] = [ { @@ -49,7 +51,9 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { ) { app.title = 'Settings' - this.plateSolver = storage.get(SETTINGS_PLATE_SOLVER_KEY, EMPTY_PLATE_SOLVER_OPTIONS) + for (const type of this.plateSolverTypes) { + this.plateSolvers.set(type, SettingsComponent.getPlateSolverOptions(storage, type)) + } } async ngAfterViewInit() { @@ -101,15 +105,26 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { } async chooseExecutablePath() { - const executablePath = await this.electron.openFile({ defaultPath: path.dirname(this.plateSolver.executablePath) }) + const options = this.plateSolvers.get(this.plateSolverType)! + const executablePath = await this.electron.openFile({ defaultPath: path.dirname(options.executablePath) }) if (executablePath) { - this.plateSolver.executablePath = executablePath + options.executablePath = executablePath this.save() } } async save() { - this.storage.set(SETTINGS_PLATE_SOLVER_KEY, this.plateSolver) + for (const type of this.plateSolverTypes) { + SettingsComponent.putPlateSolverOptions(this.storage, type, this.plateSolvers.get(type)!) + } + } + + static getPlateSolverOptions(storage: LocalStorageService, type: PlateSolverType) { + return storage.get(`${SETTINGS_PLATE_SOLVER_KEY}.${type}`, EMPTY_PLATE_SOLVER_OPTIONS) + } + + static putPlateSolverOptions(storage: StorageService, type: PlateSolverType, options: PlateSolverOptions) { + return storage.set(`${SETTINGS_PLATE_SOLVER_KEY}.${type}`, options) } } \ No newline at end of file From 0e5034c7ee90fb91e58ee3ffd2c133d806dba536 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Tue, 6 Feb 2024 00:47:02 -0300 Subject: [PATCH 06/87] [api][desktop]: Improve Flat Wizard --- api/src/main/kotlin/nebulosa/api/README.md | 35 ++++++++-- .../api/alignment/polar/tppa/TPPAJob.kt | 4 ++ .../nebulosa/api/cameras/CameraCaptureJob.kt | 4 ++ .../api/cameras/CameraExposureStep.kt | 11 ++- .../nebulosa/api/sequencer/SequencerJob.kt | 2 +- .../api/wizard/flat/FlatWizardElapsed.kt | 19 ++++-- .../api/wizard/flat/FlatWizardFailed.kt | 10 ++- .../wizard/flat/FlatWizardFrameCaptured.kt | 11 +-- .../api/wizard/flat/FlatWizardIsExposuring.kt | 14 ++++ .../nebulosa/api/wizard/flat/FlatWizardJob.kt | 8 ++- .../api/wizard/flat/FlatWizardState.kt | 7 ++ .../api/wizard/flat/FlatWizardStep.kt | 2 + .../app/filterwheel/filterwheel.component.ts | 12 ++-- .../app/flat-wizard/flat-wizard.component.ts | 68 ++++++++++++------- .../src/app/sequencer/sequencer.component.ts | 4 +- .../camera-exposure.component.html | 2 +- .../camera-exposure.component.ts | 9 +-- desktop/src/shared/pipes/exposureTime.pipe.ts | 2 +- .../src/shared/services/electron.service.ts | 10 ++- .../src/shared/services/preference.service.ts | 32 ++++----- desktop/src/shared/services/prime.service.ts | 7 +- .../shared/services/remote-storage.service.ts | 30 ++++++++ desktop/src/shared/types/flat-wizard.types.ts | 6 +- desktop/src/shared/types/sequencer.types.ts | 2 +- desktop/src/shared/types/wheel.types.ts | 4 -- .../AutoScreenTransformFunction.kt | 2 +- .../transformation/HorizontalFlip.kt | 2 +- .../algorithms/transformation/Invert.kt | 2 +- .../algorithms/transformation/VerticalFlip.kt | 2 +- .../transformation/convolution/Blur.kt | 2 +- .../transformation/convolution/Edges.kt | 2 +- .../transformation/convolution/Emboss.kt | 2 +- .../transformation/convolution/Mean.kt | 2 +- .../transformation/convolution/Sharpen.kt | 2 +- 34 files changed, 231 insertions(+), 102 deletions(-) create mode 100644 api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardIsExposuring.kt create mode 100644 api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardState.kt create mode 100644 desktop/src/shared/services/remote-storage.service.ts diff --git a/api/src/main/kotlin/nebulosa/api/README.md b/api/src/main/kotlin/nebulosa/api/README.md index 19a28ce23..83ffe4b43 100644 --- a/api/src/main/kotlin/nebulosa/api/README.md +++ b/api/src/main/kotlin/nebulosa/api/README.md @@ -71,7 +71,7 @@ URL: `localhost:{PORT}/ws` ```json5 { "camera": {}, - "state": "EXPOSURING", + "state": "CAPTURE_STARTED|EXPOSURE_STARTED|EXPOSURING|WAITING|SETTLING|EXPOSURE_FINISHED|CAPTURE_FINISHED", "exposureAmount": 0, "exposureCount": 0, "captureElapsedTime": 0, @@ -190,7 +190,7 @@ URL: `localhost:{PORT}/ws` "remainingTime": 0, "progress": 0.0, "direction": "EAST", - "state": "FORWARD" + "state": "FORWARD|BACKWARD" } ``` @@ -200,23 +200,46 @@ URL: `localhost:{PORT}/ws` ```json5 { + "state": "EXPOSURING|CAPTURED|FAILED", "exposureTime": 0, + "savedPath": "", + // CAMERA.CAPTURE_ELAPSED + "capture": {}, + "message": "" +} +``` + +### Sequencer + +#### SEQUENCER.ELAPSED + +```json5 +{ + "id": 0, + "elapsedTime": 0, + "remainingTime": 0, + "progress": 0.0, + // CAMERA.CAPTURE_ELAPSED "capture": {} } ``` -#### FLAT_WIZARD.FRAME_CAPTURED +### INDI + +#### DEVICE.PROPERTY_CHANGED, DEVICE.PROPERTY_DELETED ```json5 { - "exposureTime": 0, - "savedPath": "" + "device": {}, + "property": {} } ``` -#### FLAT_WIZARD.FAILED +#### DEVICE.MESSAGE_RECEIVED ```json5 { + "device": {}, + "message": "" } ``` diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt index 76fd816ba..2830af764 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt @@ -40,4 +40,8 @@ data class TPPAJob( register(tppaStep) } + + override fun contains(data: Any): Boolean { + return data === camera || data === mount || super.contains(data) + } } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt index 718f378be..9f0337f2f 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt @@ -48,4 +48,8 @@ data class CameraCaptureJob( cameraExposureStep.registerCameraCaptureListener(cameraCaptureEventHandler) } + + override fun contains(data: Any): Boolean { + return data === camera || super.contains(data) + } } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt index eeac72a3d..8e8f50cf5 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt @@ -4,6 +4,7 @@ import nebulosa.api.guiding.WaitForSettleListener import nebulosa.api.guiding.WaitForSettleStep import nebulosa.batch.processing.ExecutionContext import nebulosa.batch.processing.ExecutionContext.Companion.getDuration +import nebulosa.batch.processing.ExecutionContext.Companion.getInt import nebulosa.batch.processing.JobExecution import nebulosa.batch.processing.StepExecution import nebulosa.batch.processing.StepResult @@ -28,6 +29,7 @@ import kotlin.io.path.outputStream data class CameraExposureStep( override val camera: Camera, override val request: CameraStartCaptureRequest, + private val virtualLoop: Boolean = false, ) : CameraStartCaptureStep, DelayStepListener, WaitForSettleListener { @JvmField val exposureTime = request.exposureTime @@ -83,6 +85,8 @@ data class CameraExposureStep( override fun beforeJob(jobExecution: JobExecution) { camera.enableBlob() + exposureCount = jobExecution.context.getInt(EXPOSURE_COUNT, exposureCount) + captureElapsedTime = jobExecution.context.getDuration(CAPTURE_ELAPSED_TIME, captureElapsedTime) jobExecution.context.populateExecutionContext(Duration.ZERO, estimatedCaptureTime, 0.0) listeners.forEach { it.onCaptureStarted(this, jobExecution) } } @@ -127,7 +131,7 @@ data class CameraExposureStep( private fun executeCapture(stepExecution: StepExecution) { if (camera.connected && !aborted) { synchronized(camera) { - LOG.debug { "camera exposure started. estimatedCaptureTime=$estimatedCaptureTime, request=$request" } + LOG.debug { "camera exposure started. estimatedCaptureTime=$estimatedCaptureTime, request=$request, context=${stepExecution.context}" } latch.countUp() @@ -149,8 +153,9 @@ data class CameraExposureStep( latch.await() captureElapsedTime += exposureTime + stepExecution.context[CAPTURE_ELAPSED_TIME] = captureElapsedTime - LOG.debug { "camera exposure finished. aborted=$aborted, camera=$camera" } + LOG.debug { "camera exposure finished. aborted=$aborted, camera=$camera, context=${stepExecution.context}" } } } } @@ -183,7 +188,7 @@ data class CameraExposureStep( var captureRemainingTime = Duration.ZERO var captureProgress = 0.0 - if (!request.isLoop) { + if (!request.isLoop && !virtualLoop) { captureRemainingTime = if (estimatedCaptureTime > captureElapsedTime) estimatedCaptureTime - captureElapsedTime else Duration.ZERO captureProgress = (estimatedCaptureTime - captureRemainingTime).toNanos().toDouble() / estimatedCaptureTime.toNanos() } diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt index b7a309be6..1f0bdb4fc 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt @@ -196,7 +196,7 @@ data class SequencerJob( } override fun contains(data: Any): Boolean { - return data === camera || super.contains(data) + return data === camera || data === focuser || data === wheel || super.contains(data) } private data class SequenceIdStep(private val id: Int) : Step { diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardElapsed.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardElapsed.kt index af16978c0..f60a71466 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardElapsed.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardElapsed.kt @@ -2,12 +2,21 @@ package nebulosa.api.wizard.flat import nebulosa.api.cameras.CameraCaptureElapsed import nebulosa.api.messages.MessageEvent +import java.nio.file.Path import java.time.Duration -data class FlatWizardElapsed( - val exposureTime: Duration, - val capture: CameraCaptureElapsed, -) : MessageEvent { +sealed interface FlatWizardElapsed : MessageEvent { - override val eventName = "FLAT_WIZARD.ELAPSED" + val state: FlatWizardState + + val exposureTime: Duration + + val capture: CameraCaptureElapsed? + + val savedPath: Path? + + val message: String + + override val eventName + get() = "FLAT_WIZARD.ELAPSED" } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardFailed.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardFailed.kt index 5f1afc8f3..6a862e6df 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardFailed.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardFailed.kt @@ -1,8 +1,12 @@ package nebulosa.api.wizard.flat -import nebulosa.api.messages.MessageEvent +import java.time.Duration -object FlatWizardFailed : MessageEvent { +data object FlatWizardFailed : FlatWizardElapsed { - override val eventName = "FLAT_WIZARD.FAILED" + override val state = FlatWizardState.FAILED + override val exposureTime: Duration = Duration.ZERO + override val capture = null + override val savedPath = null + override val message = "" } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardFrameCaptured.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardFrameCaptured.kt index 96b030659..565df85f6 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardFrameCaptured.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardFrameCaptured.kt @@ -1,13 +1,14 @@ package nebulosa.api.wizard.flat -import nebulosa.api.messages.MessageEvent import java.nio.file.Path import java.time.Duration data class FlatWizardFrameCaptured( - val savedPath: Path, - val exposureTime: Duration, -) : MessageEvent { + override val exposureTime: Duration, + override val savedPath: Path, +) : FlatWizardElapsed { - override val eventName = "FLAT_WIZARD.FRAME_CAPTURED" + override val state = FlatWizardState.CAPTURED + override val capture = null + override val message = "" } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardIsExposuring.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardIsExposuring.kt new file mode 100644 index 000000000..a412c1289 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardIsExposuring.kt @@ -0,0 +1,14 @@ +package nebulosa.api.wizard.flat + +import nebulosa.api.cameras.CameraCaptureElapsed +import java.time.Duration + +data class FlatWizardIsExposuring( + override val exposureTime: Duration, + override val capture: CameraCaptureElapsed, +) : FlatWizardElapsed { + + override val state = FlatWizardState.EXPOSURING + override val savedPath = null + override val message = "" +} diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt index fe34f6296..100b6c041 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt @@ -30,7 +30,7 @@ data class FlatWizardJob( } override fun onFlatCaptured(step: FlatWizardStep, savedPath: Path, duration: Duration) { - super.onNext(FlatWizardFrameCaptured(savedPath, duration)) + super.onNext(FlatWizardFrameCaptured(duration, savedPath)) } override fun onFlatFailed(step: FlatWizardStep) { @@ -39,7 +39,7 @@ data class FlatWizardJob( override fun onNext(event: MessageEvent) { if (event is CameraCaptureElapsed) { - super.onNext(FlatWizardElapsed(step.exposureTime, event)) + super.onNext(FlatWizardIsExposuring(step.exposureTime, event)) // Notify Camera window to retrieve new image. if (event is CameraExposureFinished) { @@ -47,4 +47,8 @@ data class FlatWizardJob( } } } + + override fun contains(data: Any): Boolean { + return data === camera || data === wheel || super.contains(data) + } } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardState.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardState.kt new file mode 100644 index 000000000..06bc8ec4a --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardState.kt @@ -0,0 +1,7 @@ +package nebulosa.api.wizard.flat + +enum class FlatWizardState { + EXPOSURING, + CAPTURED, + FAILED, +} diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt index fbfae0012..9754b446e 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt @@ -59,6 +59,8 @@ data class FlatWizardStep( } override fun execute(stepExecution: StepExecution): StepResult { + if (stopped) return StepResult.FINISHED + val delta = exposureMax.toMillis() - exposureMin.toMillis() if (delta < 10) { diff --git a/desktop/src/app/filterwheel/filterwheel.component.ts b/desktop/src/app/filterwheel/filterwheel.component.ts index 69f0305f0..e0a986388 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.ts +++ b/desktop/src/app/filterwheel/filterwheel.component.ts @@ -5,9 +5,9 @@ import { Subject, Subscription, debounceTime } from 'rxjs' import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' import { ElectronService } from '../../shared/services/electron.service' -import { LocalStorageService } from '../../shared/services/local-storage.service' +import { PreferenceService } from '../../shared/services/preference.service' import { CameraStartCapture, EMPTY_CAMERA_START_CAPTURE } from '../../shared/types/camera.types' -import { EMPTY_WHEEL, FilterSlot, FilterWheel, WheelDialogInput, WheelDialogMode, WheelPreference, wheelPreferenceKey } from '../../shared/types/wheel.types' +import { EMPTY_WHEEL, FilterSlot, FilterWheel, WheelDialogInput, WheelDialogMode, WheelPreference } from '../../shared/types/wheel.types' import { AppComponent } from '../app.component' @Component({ @@ -54,7 +54,7 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { private app: AppComponent, private api: ApiService, private electron: ElectronService, - private storage: LocalStorageService, + private preference: PreferenceService, private route: ActivatedRoute, ngZone: NgZone, ) { @@ -171,7 +171,7 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { filters = this.filters } - const preference = this.storage.get(wheelPreferenceKey(this.wheel), {}) + const preference = this.preference.wheelPreferenceGet(this.wheel) for (let position = 1; position <= filters.length; position++) { const name = preference.names?.[position - 1] ?? `Filter #${position}` @@ -187,7 +187,7 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { private loadPreference() { if (this.mode === 'CAPTURE' && this.wheel.name) { - const preference = this.storage.get(wheelPreferenceKey(this.wheel), {}) + const preference = this.preference.wheelPreferenceGet(this.wheel) const shutterPosition = preference.shutterPosition ?? 0 this.filters.forEach(e => e.dark = e.position === shutterPosition) } @@ -202,7 +202,7 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { names: this.filters.map(e => e.name) } - this.storage.set(wheelPreferenceKey(this.wheel), preference) + this.preference.wheelPreferenceSet(this.wheel, preference) this.api.wheelSync(this.wheel, preference.names!) } } diff --git a/desktop/src/app/flat-wizard/flat-wizard.component.ts b/desktop/src/app/flat-wizard/flat-wizard.component.ts index 0c3922c28..e0fa6105d 100644 --- a/desktop/src/app/flat-wizard/flat-wizard.component.ts +++ b/desktop/src/app/flat-wizard/flat-wizard.component.ts @@ -1,13 +1,13 @@ import { AfterViewInit, Component, HostListener, NgZone, OnDestroy, ViewChild } from '@angular/core' -import { MessageService } from 'primeng/api' import { CameraExposureComponent } from '../../shared/components/camera-exposure/camera-exposure.component' import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' import { ElectronService } from '../../shared/services/electron.service' -import { LocalStorageService } from '../../shared/services/local-storage.service' +import { PreferenceService } from '../../shared/services/preference.service' +import { PrimeService } from '../../shared/services/prime.service' import { Camera, EMPTY_CAMERA_START_CAPTURE } from '../../shared/types/camera.types' import { FlatWizardRequest } from '../../shared/types/flat-wizard.types' -import { FilterSlot, FilterWheel, WheelPreference, wheelPreferenceKey } from '../../shared/types/wheel.types' +import { FilterSlot, FilterWheel } from '../../shared/types/wheel.types' import { AppComponent } from '../app.component' import { CameraComponent } from '../camera/camera.component' @@ -55,38 +55,52 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { private api: ApiService, electron: ElectronService, private browserWindow: BrowserWindowService, - private storage: LocalStorageService, - private message: MessageService, + private prime: PrimeService, + private preference: PreferenceService, ngZone: NgZone, ) { app.title = 'Flat Wizard' electron.on('FLAT_WIZARD.ELAPSED', event => { - if (event.capture) { + if (event.state === 'EXPOSURING' && event.capture && event.capture.camera?.name === this.camera?.name) { ngZone.run(() => { - this.cameraExposure.handleCameraCaptureEvent(event.capture!) + this.running = this.cameraExposure.handleCameraCaptureEvent(event.capture!, true) + }) + } else if (event.state === 'CAPTURED') { + ngZone.run(() => { + this.running = false + this.prime.message(`Flat frame saved at ${event.savedPath}`) + }) + } else if (event.state === 'FAILED') { + ngZone.run(() => { + this.running = false + this.prime.message(`Failed to find an optimal exposure time from given parameters`, 'error') + }) + } - if (event.capture!.state === 'CAPTURE_STARTED') { - this.running = true - } else if (event.capture!.state === 'CAPTURE_FINISHED' && event.capture!.aborted) { - this.running = false - } + if (!this.running) { + ngZone.run(() => { + this.cameraExposure.reset() }) } }) - electron.on('FLAT_WIZARD.FRAME_CAPTURED', event => { - ngZone.run(() => { - this.running = false - this.message.add({ severity: 'success', detail: `Flat frame saved at ${event.savedPath}` }) - }) + electron.on('CAMERA.UPDATED', event => { + if (event.device.name === this.camera?.name) { + ngZone.run(() => { + Object.assign(this.camera!, event.device) + this.cameraChanged() + }) + } }) - electron.on('FLAT_WIZARD.FAILED', event => { - ngZone.run(() => { - this.running = false - this.message.add({ severity: 'error', detail: `Failed to find an optimal exposure time from given parameters` }) - }) + electron.on('WHEEL.UPDATED', event => { + if (event.device.name === this.wheel?.name) { + ngZone.run(() => { + Object.assign(this.wheel!, event.device) + this.wheelChanged() + }) + } }) } @@ -99,7 +113,9 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { ngOnDestroy() { } async showCameraDialog() { - CameraComponent.showAsDialog(this.browserWindow, 'FLAT_WIZARD', this.camera!, this.request.captureRequest) + if (await CameraComponent.showAsDialog(this.browserWindow, 'FLAT_WIZARD', this.camera!, this.request.captureRequest)) { + this.preference.flatWizardCameraSet(this.camera!, this.request.captureRequest) + } } cameraChanged() { @@ -116,7 +132,7 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { private updateEntryFromCamera(camera?: Camera) { if (camera) { - const request = this.request.captureRequest + const request = this.preference.flatWizardCameraGet(camera, this.request.captureRequest) if (camera.connected) { if (camera.maxX > 1) request.x = Math.max(camera.minX, Math.min(request.x, camera.maxX)) @@ -131,6 +147,8 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { if (camera.offsetMax) request.offset = Math.max(camera.offsetMin, Math.min(request.offset, camera.offsetMax)) if (!request.frameFormat || !camera.frameFormats.includes(request.frameFormat)) request.frameFormat = camera.frameFormats[0] } + + this.request.captureRequest = request } } @@ -148,7 +166,7 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { filters = this.filters } - const preference = this.storage.get(wheelPreferenceKey(this.wheel), {}) + const preference = this.preference.wheelPreferenceGet(this.wheel) for (let position = 1; position <= filters.length; position++) { const name = preference.names?.[position - 1] ?? `Filter #${position}` diff --git a/desktop/src/app/sequencer/sequencer.component.ts b/desktop/src/app/sequencer/sequencer.component.ts index 5b83ad52b..daa08a1cd 100644 --- a/desktop/src/app/sequencer/sequencer.component.ts +++ b/desktop/src/app/sequencer/sequencer.component.ts @@ -9,7 +9,7 @@ import { LocalStorageService } from '../../shared/services/local-storage.service import { JsonFile } from '../../shared/types/app.types' import { Camera, CameraCaptureElapsed, CameraStartCapture } from '../../shared/types/camera.types' import { Focuser } from '../../shared/types/focuser.types' -import { EMPTY_SEQUENCE_PLAN, SequenceCaptureMode, SequencePlan, SequencerEvent } from '../../shared/types/sequencer.types' +import { EMPTY_SEQUENCE_PLAN, SequenceCaptureMode, SequencePlan, SequencerElapsed } from '../../shared/types/sequencer.types' import { FilterWheel } from '../../shared/types/wheel.types' import { AppComponent } from '../app.component' import { CameraComponent } from '../camera/camera.component' @@ -38,7 +38,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { readonly sequenceEvents: CameraCaptureElapsed[] = [] - event?: SequencerEvent + event?: SequencerElapsed running = false @ViewChildren('cameraExposure') diff --git a/desktop/src/shared/components/camera-exposure/camera-exposure.component.html b/desktop/src/shared/components/camera-exposure/camera-exposure.component.html index 1deaae18b..3d0feb659 100644 --- a/desktop/src/shared/components/camera-exposure/camera-exposure.component.html +++ b/desktop/src/shared/components/camera-exposure/camera-exposure.component.html @@ -1,7 +1,7 @@ - {{ exposure.count }} of {{ capture.amount }} + {{ exposure.count }} of {{ capture.amount }} 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 5210e7a20..081229f4e 100644 --- a/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts +++ b/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts @@ -20,7 +20,7 @@ export class CameraExposureComponent { @Input() readonly wait = Object.assign({}, EMPTY_CAMERA_WAIT_INFO) - handleCameraCaptureEvent(event: CameraCaptureElapsed) { + handleCameraCaptureEvent(event: CameraCaptureElapsed, looping: boolean = false) { this.capture.elapsedTime = event.captureElapsedTime this.capture.remainingTime = event.captureRemainingTime this.capture.progress = event.captureProgress @@ -37,16 +37,17 @@ export class CameraExposureComponent { } else if (event.state === 'SETTLING') { this.state = event.state } else if (event.state === 'CAPTURE_STARTED') { - this.capture.looping = event.exposureAmount <= 0 + this.capture.looping = looping || event.exposureAmount <= 0 this.capture.amount = event.exposureAmount this.state = 'EXPOSURING' } else if (event.state === 'EXPOSURE_STARTED') { this.state = 'EXPOSURING' - } else if (event.state === 'CAPTURE_FINISHED' || (!this.capture.looping && !this.capture.remainingTime)) { + } else if ((!looping && event.state === 'CAPTURE_FINISHED') || (!this.capture.looping && !this.capture.remainingTime)) { this.state = 'IDLE' } - return this.state !== undefined && this.state !== 'CAPTURE_FINISHED' && this.state !== 'IDLE' + return this.state !== undefined && this.state !== 'CAPTURE_FINISHED' + && this.state !== 'IDLE' && !event.aborted } reset() { diff --git a/desktop/src/shared/pipes/exposureTime.pipe.ts b/desktop/src/shared/pipes/exposureTime.pipe.ts index 365391ac9..8ef414d2a 100644 --- a/desktop/src/shared/pipes/exposureTime.pipe.ts +++ b/desktop/src/shared/pipes/exposureTime.pipe.ts @@ -51,7 +51,7 @@ function minutes(value: number) { } function seconds(value: number) { - return `${TWO_DIGITS_FORMATTER.format(value / 1000000)}s` + return format(value, [1000000, 1000], [secondFormatter, millisecondFormatter]) } function milliseconds(value: number) { diff --git a/desktop/src/shared/services/electron.service.ts b/desktop/src/shared/services/electron.service.ts index 4723b2c8e..e2d17eed2 100644 --- a/desktop/src/shared/services/electron.service.ts +++ b/desktop/src/shared/services/electron.service.ts @@ -13,11 +13,11 @@ import { CloseWindow, InternalEventType, JsonFile, OpenDirectory, OpenFile, Save import { Location } from '../types/atlas.types' import { Camera, CameraCaptureElapsed } from '../types/camera.types' import { INDIMessageEvent } from '../types/device.types' -import { FlatWizardEvent } from '../types/flat-wizard.types' +import { FlatWizardElapsed } from '../types/flat-wizard.types' import { Focuser } from '../types/focuser.types' import { GuideOutput, Guider, GuiderHistoryStep, GuiderMessageEvent } from '../types/guider.types' import { Mount } from '../types/mount.types' -import { SequencerEvent } from '../types/sequencer.types' +import { SequencerElapsed } from '../types/sequencer.types' import { FilterWheel } from '../types/wheel.types' import { ApiService } from './api.service' @@ -49,10 +49,8 @@ type EventMappedType = { 'DARV_ALIGNMENT.ELAPSED': DARVElapsed 'DATA.CHANGED': any 'LOCATION.CHANGED': Location - 'SEQUENCER.ELAPSED': SequencerEvent - 'FLAT_WIZARD.ELAPSED': FlatWizardEvent - 'FLAT_WIZARD.FRAME_CAPTURED': FlatWizardEvent - 'FLAT_WIZARD.FAILED': FlatWizardEvent + 'SEQUENCER.ELAPSED': SequencerElapsed + 'FLAT_WIZARD.ELAPSED': FlatWizardElapsed } @Injectable({ providedIn: 'root' }) diff --git a/desktop/src/shared/services/preference.service.ts b/desktop/src/shared/services/preference.service.ts index c6451a051..084a59f12 100644 --- a/desktop/src/shared/services/preference.service.ts +++ b/desktop/src/shared/services/preference.service.ts @@ -1,30 +1,30 @@ import { Injectable } from '@angular/core' -import { ApiService } from './api.service' -import { StorageService } from './storage.service' +import { Camera, CameraStartCapture, EMPTY_CAMERA_START_CAPTURE } from '../types/camera.types' +import { FilterWheel, WheelPreference } from '../types/wheel.types' +import { LocalStorageService } from './local-storage.service' @Injectable({ providedIn: 'root' }) -export class RemoteStorageService implements StorageService { +export class PreferenceService { - constructor(private api: ApiService) { } + constructor(private storage: LocalStorageService) { } - clear() { - return this.api.clearPreferences() - } + // WHEEL. - delete(key: string) { - return this.api.deletePreference(key) + wheelPreferenceGet(wheel: FilterWheel) { + return this.storage.get(`wheel.${wheel.name}`, {}) } - async get(key: string, defaultValue: T) { - return await this.api.getPreference(key) ?? defaultValue + wheelPreferenceSet(wheel: FilterWheel, preference: WheelPreference) { + this.storage.set(`wheel.${wheel.name}`, preference) } - has(key: string) { - return this.api.hasPreference(key) + // FLAT WIZARD. + + flatWizardCameraGet(camera: Camera, value: CameraStartCapture = EMPTY_CAMERA_START_CAPTURE) { + return this.storage.get(`flatWizard.camera.${camera.name}`, value) } - set(key: string, value: any) { - if (value === null || value === undefined) return this.delete(key) - else return this.api.setPreference(key, value) + flatWizardCameraSet(camera: Camera, capture?: CameraStartCapture) { + this.storage.set(`flatWizard.camera.${camera.name}`, capture) } } \ No newline at end of file diff --git a/desktop/src/shared/services/prime.service.ts b/desktop/src/shared/services/prime.service.ts index 63003b436..1899c0902 100644 --- a/desktop/src/shared/services/prime.service.ts +++ b/desktop/src/shared/services/prime.service.ts @@ -1,5 +1,5 @@ import { Injectable, Type } from '@angular/core' -import { ConfirmEventType, ConfirmationService } from 'primeng/api' +import { ConfirmEventType, ConfirmationService, MessageService } from 'primeng/api' import { DialogService, DynamicDialogConfig } from 'primeng/dynamicdialog' @Injectable({ providedIn: 'root' }) @@ -8,6 +8,7 @@ export class PrimeService { constructor( private dialog: DialogService, private confirmation: ConfirmationService, + private messager: MessageService, ) { } open(componentType: Type, config: DynamicDialogConfig) { @@ -51,4 +52,8 @@ export class PrimeService { }) }) } + + message(text: string, severity: 'info' | 'warn' | 'error' | 'success' = 'success') { + this.messager.add({ severity, detail: text, life: 8500 }) + } } \ No newline at end of file diff --git a/desktop/src/shared/services/remote-storage.service.ts b/desktop/src/shared/services/remote-storage.service.ts new file mode 100644 index 000000000..c6451a051 --- /dev/null +++ b/desktop/src/shared/services/remote-storage.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core' +import { ApiService } from './api.service' +import { StorageService } from './storage.service' + +@Injectable({ providedIn: 'root' }) +export class RemoteStorageService implements StorageService { + + constructor(private api: ApiService) { } + + clear() { + return this.api.clearPreferences() + } + + delete(key: string) { + return this.api.deletePreference(key) + } + + async get(key: string, defaultValue: T) { + return await this.api.getPreference(key) ?? defaultValue + } + + has(key: string) { + return this.api.hasPreference(key) + } + + set(key: string, value: any) { + if (value === null || value === undefined) return this.delete(key) + else return this.api.setPreference(key, value) + } +} \ No newline at end of file diff --git a/desktop/src/shared/types/flat-wizard.types.ts b/desktop/src/shared/types/flat-wizard.types.ts index 8e28965be..f3919befc 100644 --- a/desktop/src/shared/types/flat-wizard.types.ts +++ b/desktop/src/shared/types/flat-wizard.types.ts @@ -8,8 +8,12 @@ export interface FlatWizardRequest { meanTolerance: number } -export interface FlatWizardEvent { +export type FlatWizardState = 'EXPOSURING' | 'CAPTURED' | 'FAILED' + +export interface FlatWizardElapsed { + state: FlatWizardState exposureTime: number capture?: CameraCaptureElapsed savedPath?: string + message?: string } diff --git a/desktop/src/shared/types/sequencer.types.ts b/desktop/src/shared/types/sequencer.types.ts index 6d2f75b04..4ff02c125 100644 --- a/desktop/src/shared/types/sequencer.types.ts +++ b/desktop/src/shared/types/sequencer.types.ts @@ -57,7 +57,7 @@ export const EMPTY_SEQUENCE_PLAN: SequencePlan = { }, } -export interface SequencerEvent extends MessageEvent { +export interface SequencerElapsed extends MessageEvent { id: number elapsedTime: number remainingTime: number diff --git a/desktop/src/shared/types/wheel.types.ts b/desktop/src/shared/types/wheel.types.ts index 723cd074d..1cd21040a 100644 --- a/desktop/src/shared/types/wheel.types.ts +++ b/desktop/src/shared/types/wheel.types.ts @@ -23,10 +23,6 @@ export interface WheelDialogInput { request: CameraStartCapture } -export function wheelPreferenceKey(wheel: FilterWheel) { - return `wheel.${wheel.name}` -} - export interface WheelPreference { shutterPosition?: number names?: string[] diff --git a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/AutoScreenTransformFunction.kt b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/AutoScreenTransformFunction.kt index d2290f4b5..b40346143 100644 --- a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/AutoScreenTransformFunction.kt +++ b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/AutoScreenTransformFunction.kt @@ -9,7 +9,7 @@ import nebulosa.log.loggerFor import kotlin.math.max import kotlin.math.min -object AutoScreenTransformFunction : ComputationAlgorithm, TransformAlgorithm { +data object AutoScreenTransformFunction : ComputationAlgorithm, TransformAlgorithm { override fun compute(source: Image): ScreenTransformFunction.Parameters { // Find the median sample. diff --git a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/HorizontalFlip.kt b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/HorizontalFlip.kt index 2ff72e943..15dc72492 100644 --- a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/HorizontalFlip.kt +++ b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/HorizontalFlip.kt @@ -3,7 +3,7 @@ package nebulosa.imaging.algorithms.transformation import nebulosa.imaging.Image import nebulosa.imaging.algorithms.TransformAlgorithm -object HorizontalFlip : TransformAlgorithm { +data object HorizontalFlip : TransformAlgorithm { override fun transform(source: Image): Image { for (y in 0 until source.height) { diff --git a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/Invert.kt b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/Invert.kt index 3a4051dd6..403595497 100644 --- a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/Invert.kt +++ b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/Invert.kt @@ -3,7 +3,7 @@ package nebulosa.imaging.algorithms.transformation import nebulosa.imaging.Image import nebulosa.imaging.algorithms.TransformAlgorithm -object Invert : TransformAlgorithm { +data object Invert : TransformAlgorithm { override fun transform(source: Image): Image { for (i in source.r.indices) source.r[i] = 1f - source.r[i] diff --git a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/VerticalFlip.kt b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/VerticalFlip.kt index c09d29980..a7448fd16 100644 --- a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/VerticalFlip.kt +++ b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/VerticalFlip.kt @@ -3,7 +3,7 @@ package nebulosa.imaging.algorithms.transformation import nebulosa.imaging.Image import nebulosa.imaging.algorithms.TransformAlgorithm -object VerticalFlip : TransformAlgorithm { +data object VerticalFlip : TransformAlgorithm { override fun transform(source: Image): Image { for (y in 0 until source.height / 2) { diff --git a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Blur.kt b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Blur.kt index da1133701..d65160672 100644 --- a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Blur.kt +++ b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Blur.kt @@ -1,6 +1,6 @@ package nebulosa.imaging.algorithms.transformation.convolution -object Blur : Convolution( +data object Blur : Convolution( floatArrayOf( 1f, 2f, 3f, 2f, 1f, 2f, 4f, 5f, 4f, 2f, diff --git a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Edges.kt b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Edges.kt index 3af628796..938ad6acd 100644 --- a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Edges.kt +++ b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Edges.kt @@ -1,6 +1,6 @@ package nebulosa.imaging.algorithms.transformation.convolution -object Edges : Convolution( +data object Edges : Convolution( floatArrayOf( 0f, -1f, 0f, -1f, 4f, -1f, diff --git a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Emboss.kt b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Emboss.kt index 7521878ed..942016324 100644 --- a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Emboss.kt +++ b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Emboss.kt @@ -1,6 +1,6 @@ package nebulosa.imaging.algorithms.transformation.convolution -object Emboss : Convolution( +data object Emboss : Convolution( floatArrayOf( -1f, 0f, 0f, 0f, 0f, 0f, diff --git a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Mean.kt b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Mean.kt index 121391001..47605e0e6 100644 --- a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Mean.kt +++ b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Mean.kt @@ -1,6 +1,6 @@ package nebulosa.imaging.algorithms.transformation.convolution -object Mean : Convolution( +data object Mean : Convolution( floatArrayOf( 1f, 1f, 1f, 1f, 1f, 1f, diff --git a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Sharpen.kt b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Sharpen.kt index 5a9e644ad..07f08d63a 100644 --- a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Sharpen.kt +++ b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/algorithms/transformation/convolution/Sharpen.kt @@ -1,6 +1,6 @@ package nebulosa.imaging.algorithms.transformation.convolution -object Sharpen : Convolution( +data object Sharpen : Convolution( floatArrayOf( 0f, -1f, 0f, -1f, 5f, -1f, From 1bdd03e51307ff3bd0f04aa9825447a424b9c038 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Wed, 7 Feb 2024 15:56:22 -0300 Subject: [PATCH 07/87] [api]: Bump Gradle from 8.5 to 8.6 --- gradle/wrapper/gradle-wrapper.jar | Bin 63721 -> 43462 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 14 +++++++------- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7f93135c49b765f8051ef9d0a6055ff8e46073d8..d64cd4917707c1f8861d8cb53dd15194d4248596 100644 GIT binary patch literal 43462 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 literal 63721 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鯊%1Et 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⁢ zv5o9#E7!G0XmdzfsS{efPNi`1b44~SZ4Z8fuX!I}#8g+(wxzQwUT#Xb2(tbY1+EUhGKoT@KEU9Ktl>_0 z%bjDJg;#*gtJZv!-Zs`?^}v5eKmnbjqlvnSzE@_SP|LG_PJ6CYU+6zY6>92%E+ z=j@TZf-iW4(%U{lnYxQA;7Q!b;^brF8n0D>)`q5>|WDDXLrqYU_tKN2>=#@~OE7grMnNh?UOz-O~6 z6%rHy{#h9K0AT+lDC7q4{hw^|q6*Ry;;L%Q@)Ga}$60_q%D)rv(CtS$CQbpq9|y1e zRSrN4;$Jyl{m5bZw`$8TGvb}(LpY{-cQ)fcyJv7l3S52TLXVDsphtv&aPuDk1OzCA z4A^QtC(!11`IsNx_HnSy?>EKpHJWT^wmS~hc^p^zIIh@9f6U@I2 zC=Mve{j2^)mS#U$e{@Q?SO6%LDsXz@SY+=cK_QMmXBIU)j!$ajc-zLx3V60EXJ!qC zi<%2x8Q24YN+&8U@CIlN zrZkcT9yh%LrlGS9`G)KdP(@9Eo-AQz@8GEFWcb7U=a0H^ZVbLmz{+&M7W(nXJ4sN8 zJLR7eeK(K8`2-}j(T7JsO`L!+CvbueT%izanm-^A1Dn{`1Nw`9P?cq;7no+XfC`K(GO9?O^5zNIt4M+M8LM0=7Gz8UA@Z0N+lg+cX)NfazRu z5D)~HA^(u%w^cz+@2@_#S|u>GpB+j4KzQ^&Wcl9f z&hG#bCA(Yk0D&t&aJE^xME^&E-&xGHhXn%}psEIj641H+Nl-}boj;)Zt*t(4wZ5DN z@GXF$bL=&pBq-#vkTkh>7hl%K5|3 z{`Vn9b$iR-SoGENp}bn4;fR3>9sA%X2@1L3aE9yTra;Wb#_`xWwLSLdfu+PAu+o3| zGVnpzPr=ch{uuoHjtw7+_!L_2;knQ!DuDl0R`|%jr+}jFzXtrHIKc323?JO{l&;VF z*L1+}JU7%QJOg|5|Tc|D8fN zJORAg=_vsy{ak|o);@)Yh8Lkcg@$FG3k@ep36BRa^>~UmnRPziS>Z=`Jb2x*Q#`%A zU*i3&Vg?TluO@X0O;r2Jl6LKLUOVhSqg1*qOt^|8*c7 zo(298@+r$k_wQNGHv{|$tW(T8L+4_`FQ{kEW5Jgg{yf7ey4ss_(SNKfz(N9lx&a;< je(UuV8hP?p&}TPdm1I$XmG#(RzlD&B2izSj9sl%y5~4qc diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e6aba2515..2ea3535dc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 0adc8e1a5..1aa94a426 100755 --- a/gradlew +++ b/gradlew @@ -145,7 +145,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -153,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -202,11 +202,11 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ From b33158ae4b255e388f14e8f0829d95bb5fdf3cd4 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 10 Feb 2024 22:39:34 -0300 Subject: [PATCH 08/87] [api]: Implement Three Point Polar Alignment --- .../api/alignment/polar/darv/DARVExecutor.kt | 2 +- .../api/alignment/polar/tppa/TPPAExecutor.kt | 9 +- .../api/alignment/polar/tppa/TPPAJob.kt | 8 +- .../alignment/polar/tppa/TPPAStartRequest.kt | 2 +- .../api/alignment/polar/tppa/TPPAStep.kt | 64 ++++++++--- .../api/atlas/SimbadDatabaseReader.kt | 4 +- .../atlas/ephemeris/BodyEphemerisProvider.kt | 2 +- .../api/cameras/CameraCaptureExecutor.kt | 4 +- .../api/cameras/CameraExposureStep.kt | 13 ++- .../kotlin/nebulosa/api/image/ImageSolved.kt | 4 +- .../nebulosa/api/messages/MessageService.kt | 2 +- .../nebulosa/api/messages/QueueableEvent.kt | 3 + .../nebulosa/api/mounts/MountSerializer.kt | 8 +- .../nebulosa/api/mounts/MountService.kt | 15 +-- .../nebulosa/api/mounts/MountSlewStep.kt | 27 +++-- .../api/notifications/NotificationEvent.kt | 3 +- .../api/sequencer/SequencerExecutor.kt | 4 +- .../api/wizard/flat/FlatWizardExecutor.kt | 18 +-- .../api/wizard/flat/FlatWizardStep.kt | 4 +- nebulosa-alignment/build.gradle.kts | 1 - .../point/three/PolarErrorDetermination.kt | 89 ++++++--------- .../alignment/polar/point/three/Position.kt | 100 ++++++++--------- .../point/three/ThreePointPolarAlignment.kt | 96 +++++++--------- .../three/ThreePointPolarAlignmentResult.kt | 9 +- .../polar/point/three/Topocentric.kt | 2 - .../kotlin/ThreePointPolarAlignmentTest.kt | 103 ++++++++++++++++++ .../nebulosa/batch/processing}/JobExecutor.kt | 5 +- .../kotlin/nebulosa/batch/processing/Step.kt | 10 ++ .../src/main/kotlin/nebulosa/erfa/Erfa.kt | 13 +-- .../indi/client/device/mount/MountDevice.kt | 14 +-- .../src/main/kotlin/nebulosa/math/Angle.kt | 6 + .../src/main/kotlin/nebulosa/math/Pressure.kt | 2 +- .../src/main/kotlin/nebulosa/math/Vector3D.kt | 9 +- nebulosa-math/src/test/kotlin/AngleTest.kt | 10 +- nebulosa-math/src/test/kotlin/Vector3DTest.kt | 9 ++ .../nebulosa/nova/position/Barycentric.kt | 4 +- .../nova/position/GeographicPosition.kt | 63 ++++++++--- .../kotlin/nebulosa/nova/position/ICRF.kt | 55 +++++----- .../src/test/kotlin/GeographicPositionTest.kt | 9 +- nebulosa-nova/src/test/kotlin/ICRFTest.kt | 17 ++- .../nebulosa/plate/solving/PlateSolution.kt | 4 +- .../src/test/kotlin/PlateSolutionTest.kt | 8 +- .../kotlin/nebulosa/simbad/SimbadService.kt | 4 +- .../nebulosa/skycatalog/hyg/HygDatabase.kt | 4 +- .../nebulosa/skycatalog/stellarium/Nebula.kt | 4 +- .../kotlin/nebulosa/skycatalog/SkyObject.kt | 11 +- .../main/kotlin/nebulosa/time/CurrentTime.kt | 5 +- .../src/main/kotlin/nebulosa/time/TT.kt | 10 ++ .../src/main/kotlin/nebulosa/time/TimeJD.kt | 14 +-- .../src/main/kotlin/nebulosa/time/UTC.kt | 4 + nebulosa-wcs/src/test/kotlin/LibWCSTest.kt | 14 +-- 51 files changed, 525 insertions(+), 379 deletions(-) create mode 100644 api/src/main/kotlin/nebulosa/api/messages/QueueableEvent.kt create mode 100644 nebulosa-alignment/src/test/kotlin/ThreePointPolarAlignmentTest.kt rename {api/src/main/kotlin/nebulosa/api/jobs => nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing}/JobExecutor.kt (91%) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt index 7250060d3..1bf73d431 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt @@ -1,7 +1,7 @@ package nebulosa.api.alignment.polar.darv -import nebulosa.api.jobs.JobExecutor import nebulosa.api.messages.MessageService +import nebulosa.batch.processing.JobExecutor import nebulosa.batch.processing.JobLauncher import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.guide.GuideOutput diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt index a81bff22f..94a83866a 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt @@ -1,15 +1,13 @@ package nebulosa.api.alignment.polar.tppa -import nebulosa.api.jobs.JobExecutor import nebulosa.api.messages.MessageService import nebulosa.api.solver.PlateSolverService +import nebulosa.batch.processing.JobExecutor import nebulosa.batch.processing.JobLauncher -import nebulosa.imaging.Image import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.mount.Mount import nebulosa.log.info import nebulosa.log.loggerFor -import nebulosa.star.detection.StarDetector import org.springframework.stereotype.Component @Component @@ -17,7 +15,6 @@ class TPPAExecutor( override val jobLauncher: JobLauncher, private val messageService: MessageService, private val plateSolverService: PlateSolverService, - private val starDetector: StarDetector, ) : JobExecutor() { @Synchronized @@ -26,9 +23,9 @@ class TPPAExecutor( LOG.info { "starting TPPA. camera=$camera, mount=$mount, request=$request" } - val solver = plateSolverService.solverFor(request.plateSolverOptions) + val solver = plateSolverService.solverFor(request.plateSolver) - return with(TPPAJob(camera, request, solver, starDetector, mount)) { + return with(TPPAJob(camera, request, solver, mount)) { subscribe(messageService::sendMessage) register(jobLauncher.launch(this)) id diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt index 2830af764..e94627779 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt @@ -7,12 +7,11 @@ import nebulosa.api.cameras.CameraCaptureListener import nebulosa.api.messages.MessageEvent import nebulosa.batch.processing.PublishSubscribe import nebulosa.batch.processing.SimpleJob -import nebulosa.imaging.Image import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.FrameType import nebulosa.indi.device.mount.Mount +import nebulosa.math.Angle import nebulosa.plate.solving.PlateSolver -import nebulosa.star.detection.StarDetector import java.nio.file.Files import java.time.Duration @@ -20,8 +19,9 @@ data class TPPAJob( @JvmField val camera: Camera, @JvmField val request: TPPAStartRequest, @JvmField val solver: PlateSolver, - @JvmField val starDetector: StarDetector, @JvmField val mount: Mount? = null, + @JvmField val longitude: Angle = mount!!.longitude, + @JvmField val latitude: Angle = mount!!.latitude, ) : SimpleJob(), PublishSubscribe, CameraCaptureListener { @JvmField val cameraRequest = request.capture.copy( @@ -33,7 +33,7 @@ data class TPPAJob( override val subject = PublishSubject.create() private val cameraCaptureEventHandler = CameraCaptureEventHandler(this) - private val tppaStep = TPPAStep(camera, solver, starDetector, request, mount, cameraRequest) + private val tppaStep = TPPAStep(camera, solver, request, mount, longitude, latitude, cameraRequest) init { tppaStep.registerCameraCaptureListener(cameraCaptureEventHandler) 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 4b55431ec..db1a82df7 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 @@ -8,7 +8,7 @@ import nebulosa.api.solver.PlateSolverOptions data class TPPAStartRequest( @JsonIgnoreProperties("camera", "focuser", "wheel") val capture: CameraStartCaptureRequest = CameraStartCaptureRequest.EMPTY, - @field:NotNull @Valid val plateSolverOptions: PlateSolverOptions = PlateSolverOptions.EMPTY, + @field:NotNull @Valid val plateSolver: PlateSolverOptions = PlateSolverOptions.EMPTY, val startFromCurrentPosition: Boolean = true, val eastDirection: Boolean = true, val refractionAdjustment: Boolean = false, diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt index 2f9fbd51e..037e8eed3 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt @@ -14,22 +14,28 @@ import nebulosa.fits.Fits import nebulosa.imaging.Image import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.mount.Mount +import nebulosa.log.debug +import nebulosa.log.loggerFor +import nebulosa.math.Angle import nebulosa.math.deg import nebulosa.plate.solving.PlateSolver -import nebulosa.star.detection.StarDetector data class TPPAStep( private val camera: Camera, private val solver: PlateSolver, - private val starDetector: StarDetector, private val request: TPPAStartRequest, private val mount: Mount? = null, + private val longitude: Angle = mount!!.longitude, + private val latitude: Angle = mount!!.latitude, private val cameraRequest: CameraStartCaptureRequest = request.capture, ) : Step { private val cameraExposureStep = CameraExposureStep(camera, cameraRequest) - private val alignment = ThreePointPolarAlignment(solver, starDetector) + private val alignment = ThreePointPolarAlignment(solver, longitude, latitude) @Volatile private var image: Image? = null + @Volatile private var mountSlewStep: MountSlewStep? = null + @Volatile private var stopped = false + @Volatile private var noSolutionAttempts = 0 fun registerCameraCaptureListener(listener: CameraCaptureListener): Boolean { return cameraExposureStep.registerCameraCaptureListener(listener) @@ -40,38 +46,64 @@ data class TPPAStep( } override fun beforeJob(jobExecution: JobExecution) { + cameraExposureStep.beforeJob(jobExecution) mount?.tracking(true) } override fun afterJob(jobExecution: JobExecution) { + cameraExposureStep.afterJob(jobExecution) + if (mount != null && request.stopTrackingWhenDone) { mount.tracking(false) } } override fun execute(stepExecution: StepExecution): StepResult { + if (stopped) return StepResult.FINISHED + + LOG.debug { "executing TPPA. camera=$camera, mount=$mount, state=${alignment.state}" } + if (mount != null) { - if (alignment.state == ThreePointPolarAlignment.State.SECOND_MEASURE || - alignment.state == ThreePointPolarAlignment.State.THIRD_MEASURE - ) { - val slewStep = MountSlewStep(mount, mount.rightAscension + 10.deg, mount.declination) - slewStep.execute(stepExecution) + if (alignment.state in 1..2) { + val step = MountSlewStep(mount, mount.rightAscension + 10.deg, mount.declination) + mountSlewStep = step + step.executeSingle(stepExecution) } } + if (stopped) return StepResult.FINISHED + cameraExposureStep.execute(stepExecution) - val savedPath = cameraExposureStep.savedPath ?: return StepResult.FINISHED - image = Fits(savedPath).also(Fits::read).use { image?.load(it, false) ?: Image.open(it, false) } - val radius = if (mount == null) 0.0 else ThreePointPolarAlignment.DEFAULT_RADIUS - val result = alignment.align(savedPath, image!!, mount?.rightAscension ?: 0.0, mount?.declination ?: 0.0, radius) + if (!stopped) { + val savedPath = cameraExposureStep.savedPath ?: return StepResult.FINISHED + image = Fits(savedPath).also(Fits::read).use { image?.load(it, false) ?: Image.open(it, false) } + val radius = if (mount == null) 0.0 else ThreePointPolarAlignment.DEFAULT_RADIUS + val result = alignment.align(savedPath, image!!, mount?.rightAscension ?: 0.0, mount?.declination ?: 0.0, radius) + + LOG.info("alignment completed. result=$result") - if (result === ThreePointPolarAlignmentResult.NeedMoreMeasure) { - return StepResult.CONTINUABLE - } else if (result === ThreePointPolarAlignmentResult.NoPlateSolution) { - return StepResult.FINISHED + if (result is ThreePointPolarAlignmentResult.NeedMoreMeasurement) { + noSolutionAttempts = 0 + return StepResult.CONTINUABLE + } else if (result is ThreePointPolarAlignmentResult.NoPlateSolution) { + noSolutionAttempts++ + return if (noSolutionAttempts < 10) StepResult.CONTINUABLE + else StepResult.FINISHED + } } return StepResult.FINISHED } + + override fun stop(mayInterruptIfRunning: Boolean) { + stopped = true + mountSlewStep?.stop(mayInterruptIfRunning) + cameraExposureStep.stop(mayInterruptIfRunning) + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } } diff --git a/api/src/main/kotlin/nebulosa/api/atlas/SimbadDatabaseReader.kt b/api/src/main/kotlin/nebulosa/api/atlas/SimbadDatabaseReader.kt index 4df5c3b4a..5c6b182bb 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/SimbadDatabaseReader.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/SimbadDatabaseReader.kt @@ -6,7 +6,6 @@ import nebulosa.math.kms import nebulosa.math.mas import nebulosa.skycatalog.SkyObject import nebulosa.skycatalog.SkyObjectType -import nebulosa.time.CurrentTime import okio.BufferedSource import okio.Source import okio.buffer @@ -16,7 +15,6 @@ import java.io.Closeable class SimbadDatabaseReader(source: Source) : Iterator, Closeable { private val buffer = if (source is BufferedSource) source else source.gzip().buffer() - private val time = CurrentTime.utc override fun hasNext() = !buffer.exhausted() @@ -34,7 +32,7 @@ class SimbadDatabaseReader(source: Source) : Iterator, Closeable { val radialVelocity = buffer.readFloat().toDouble().kms val redshift = 0.0 // buffer.readDouble() // val constellation = Constellation.entries[buffer.readByte().toInt() and 0xFF] - val constellation = SkyObject.constellationFor(rightAscension, declination, time) + val constellation = SkyObject.constellationFor(rightAscension, declination) return SimbadEntity(id, name, type, rightAscension, declination, magnitude, pmRA, pmDEC, parallax, radialVelocity, redshift, constellation) } diff --git a/api/src/main/kotlin/nebulosa/api/atlas/ephemeris/BodyEphemerisProvider.kt b/api/src/main/kotlin/nebulosa/api/atlas/ephemeris/BodyEphemerisProvider.kt index dac5f3ced..e2c9cb715 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/ephemeris/BodyEphemerisProvider.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/ephemeris/BodyEphemerisProvider.kt @@ -37,7 +37,7 @@ class BodyEphemerisProvider : CachedEphemerisProvider() { val astrometric = site.at(utc).observe(target) val (az, alt) = astrometric.horizontal() val (ra, dec) = astrometric.equatorialAtDate() - val (raJ2000, decJ2000) = astrometric.equatorialJ2000() + val (raJ2000, decJ2000) = astrometric.equatorial() val element = HorizonsElement(time) element[HorizonsQuantity.ASTROMETRIC_RA] = "${raJ2000.normalized.toDegrees}" diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt index 2e8013ab8..ce1f21eae 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt @@ -1,7 +1,7 @@ package nebulosa.api.cameras -import nebulosa.api.jobs.JobExecutor import nebulosa.api.messages.MessageService +import nebulosa.batch.processing.JobExecutor import nebulosa.batch.processing.JobLauncher import nebulosa.guiding.Guider import nebulosa.indi.device.camera.Camera @@ -25,7 +25,7 @@ class CameraCaptureExecutor( val cameraCaptureJob = CameraCaptureJob(camera, request, guider) cameraCaptureJob.subscribe(messageService::sendMessage) - jobExecutions.add(jobLauncher.launch(cameraCaptureJob)) + register(jobLauncher.launch(cameraCaptureJob)) } fun stop(camera: Camera) { diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt index 8e8f50cf5..59936ddd8 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt @@ -99,10 +99,13 @@ data class CameraExposureStep( } override fun execute(stepExecution: StepExecution): StepResult { - this.stepExecution = stepExecution - EventBus.getDefault().register(this) - executeCapture(stepExecution) - EventBus.getDefault().unregister(this) + if (request.isLoop || estimatedCaptureTime > Duration.ZERO) { + this.stepExecution = stepExecution + EventBus.getDefault().register(this) + executeCapture(stepExecution) + EventBus.getDefault().unregister(this) + } + return StepResult.FINISHED } @@ -157,6 +160,8 @@ data class CameraExposureStep( LOG.debug { "camera exposure finished. aborted=$aborted, camera=$camera, context=${stepExecution.context}" } } + } else { + LOG.warn("camera not connected or aborted. aborted=$aborted, camera=$camera") } } diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt b/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt index 3d3992184..d9ab40126 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt @@ -16,8 +16,8 @@ data class ImageSolved( constructor(solution: PlateSolution) : this( solution.orientation.toDegrees, solution.scale.toArcsec, - solution.rightAscension.format(AngleFormatter.HMS), - solution.declination.format(AngleFormatter.SIGNED_DMS), + solution.rightAscension.formatHMS(), + solution.declination.formatSignedDMS(), solution.width.toArcmin, solution.height.toArcmin, solution.radius.toDegrees, ) diff --git a/api/src/main/kotlin/nebulosa/api/messages/MessageService.kt b/api/src/main/kotlin/nebulosa/api/messages/MessageService.kt index af5f4b90d..0a4206e7c 100644 --- a/api/src/main/kotlin/nebulosa/api/messages/MessageService.kt +++ b/api/src/main/kotlin/nebulosa/api/messages/MessageService.kt @@ -38,7 +38,7 @@ class MessageService( fun sendMessage(event: MessageEvent) { if (connected.get()) { simpleMessageTemplate.convertAndSend(EVENT_NAME, event) - } else { + } else if (event is QueueableEvent) { LOG.debug { "queueing message. event=$event" } messageQueue.offer(event) } diff --git a/api/src/main/kotlin/nebulosa/api/messages/QueueableEvent.kt b/api/src/main/kotlin/nebulosa/api/messages/QueueableEvent.kt new file mode 100644 index 000000000..c1a9bfb8a --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/messages/QueueableEvent.kt @@ -0,0 +1,3 @@ +package nebulosa.api.messages + +interface QueueableEvent diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountSerializer.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountSerializer.kt index 8fab6ee19..db207b430 100644 --- a/api/src/main/kotlin/nebulosa/api/mounts/MountSerializer.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountSerializer.kt @@ -4,8 +4,8 @@ import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.ser.std.StdSerializer import nebulosa.indi.device.mount.Mount -import nebulosa.math.AngleFormatter -import nebulosa.math.format +import nebulosa.math.formatHMS +import nebulosa.math.formatSignedDMS import nebulosa.math.toDegrees import nebulosa.math.toMeters import org.springframework.stereotype.Component @@ -32,8 +32,8 @@ class MountSerializer : StdSerializer(Mount::class.java) { gen.writeStringField("pierSide", value.pierSide.name) gen.writeNumberField("guideRateWE", value.guideRateWE) gen.writeNumberField("guideRateNS", value.guideRateNS) - gen.writeStringField("rightAscension", value.rightAscension.format(AngleFormatter.HMS)) - gen.writeStringField("declination", value.declination.format(AngleFormatter.SIGNED_DMS)) + gen.writeStringField("rightAscension", value.rightAscension.formatHMS()) + gen.writeStringField("declination", value.declination.formatSignedDMS()) gen.writeBooleanField("canPulseGuide", value.canPulseGuide) gen.writeBooleanField("pulseGuiding", value.pulseGuiding) gen.writeBooleanField("canPark", value.canPark) diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt index 8f0a60570..c97723dbe 100644 --- a/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt @@ -16,6 +16,8 @@ import nebulosa.nova.position.GeographicPosition import nebulosa.nova.position.Geoid import nebulosa.nova.position.ICRF import nebulosa.time.CurrentTime +import nebulosa.time.TimeJD +import nebulosa.time.UTC import nebulosa.wcs.WCS import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -168,15 +170,14 @@ class MountService(private val imageBucket: ImageBucket) { val computedLocation = ComputedLocation() val center = site[mount]!! - val time = CurrentTime - val epoch = if (j2000) null else time + val epoch = if (j2000) null else CurrentTime - val icrf = ICRF.equatorial(rightAscension, declination, time = time, epoch = epoch, center = center) + val icrf = ICRF.equatorial(rightAscension, declination, epoch = epoch, center = center) computedLocation.constellation = Constellation.find(icrf) if (j2000) { if (equatorial) { - val raDec = icrf.equatorialAtDate() + val raDec = icrf.equatorialAtEpoch(CurrentTime) computedLocation.rightAscension = raDec.longitude.normalized computedLocation.declination = raDec.latitude } @@ -185,7 +186,7 @@ class MountService(private val imageBucket: ImageBucket) { computedLocation.declinationJ2000 = declination } else { if (equatorial) { - val raDec = icrf.equatorialJ2000() + val raDec = icrf.equatorial() computedLocation.rightAscensionJ2000 = raDec.longitude.normalized computedLocation.declinationJ2000 = raDec.latitude } @@ -223,8 +224,8 @@ class MountService(private val imageBucket: ImageBucket) { val raOffset = mount.rightAscension - calibratedRA val decOffset = mount.declination - calibratedDEC LOG.info( - "pointing mount adjusted. ra={}, dec={}, dx={}, dy={}", rightAscension.format(AngleFormatter.HMS), - declination.format(AngleFormatter.SIGNED_DMS), raOffset.format(AngleFormatter.HMS), decOffset.format(AngleFormatter.SIGNED_DMS) + "pointing mount adjusted. ra={}, dec={}, dx={}, dy={}", rightAscension.formatHMS(), + declination.formatSignedDMS(), raOffset.formatHMS(), decOffset.formatSignedDMS() ) goTo(mount, rightAscension + raOffset, declination + decOffset, true) } diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt index aba3d1a41..720a37be7 100644 --- a/api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt @@ -10,6 +10,8 @@ import nebulosa.indi.device.mount.MountSlewFailed import nebulosa.indi.device.mount.MountSlewingChanged import nebulosa.log.loggerFor import nebulosa.math.Angle +import nebulosa.math.formatHMS +import nebulosa.math.formatSignedDMS import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -26,26 +28,30 @@ data class MountSlewStep( private val initialDEC = mount.declination @Subscribe(threadMode = ThreadMode.ASYNC) - fun onFilterWheelEvent(event: MountEvent) { - if (event is MountSlewingChanged) { - if (!mount.slewing && mount.rightAscension != initialRA && mount.declination != initialDEC) { + fun onMountEvent(event: MountEvent) { + if (event.device === mount) { + if (event is MountSlewingChanged) { + if (!mount.slewing && (mount.rightAscension != initialRA || mount.declination != initialDEC)) { + latch.reset() + } + } else if (event is MountSlewFailed) { + LOG.warn("failed to slew mount. mount={}", mount) latch.reset() } - } else if (event is MountSlewFailed) { - LOG.warn("failed to slew mount. mount={}", mount) - latch.reset() } } override fun execute(stepExecution: StepExecution): StepResult { - if (mount.connected && rightAscension.isFinite() && - declination.isFinite() && mount.rightAscension != initialRA - && mount.declination != initialDEC + if (mount.connected && + rightAscension.isFinite() && declination.isFinite() && + (mount.rightAscension != rightAscension || mount.declination != declination) ) { EventBus.getDefault().register(this) latch.countUp() + LOG.info("moving mount. mount={}, ra={}, dec={}", mount, mount.rightAscension.formatHMS(), mount.declination.formatSignedDMS()) + if (j2000) { if (goTo) mount.goToJ2000(rightAscension, declination) else mount.slewToJ2000(rightAscension, declination) @@ -56,6 +62,8 @@ data class MountSlewStep( latch.await() + LOG.info("mount moved. mount={}, ra={}, dec={}", mount, mount.rightAscension.formatHMS(), mount.declination.formatSignedDMS()) + EventBus.getDefault().unregister(this) } @@ -63,6 +71,7 @@ data class MountSlewStep( } override fun stop(mayInterruptIfRunning: Boolean) { + mount.abortMotion() latch.reset() } diff --git a/api/src/main/kotlin/nebulosa/api/notifications/NotificationEvent.kt b/api/src/main/kotlin/nebulosa/api/notifications/NotificationEvent.kt index 13813967c..266876056 100644 --- a/api/src/main/kotlin/nebulosa/api/notifications/NotificationEvent.kt +++ b/api/src/main/kotlin/nebulosa/api/notifications/NotificationEvent.kt @@ -1,8 +1,9 @@ package nebulosa.api.notifications import nebulosa.api.messages.MessageEvent +import nebulosa.api.messages.QueueableEvent -interface NotificationEvent : MessageEvent { +interface NotificationEvent : MessageEvent, QueueableEvent { enum class Severity { INFO, diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt index d86288955..079c67c4e 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt @@ -1,7 +1,7 @@ package nebulosa.api.sequencer -import nebulosa.api.jobs.JobExecutor import nebulosa.api.messages.MessageService +import nebulosa.batch.processing.JobExecutor import nebulosa.batch.processing.JobLauncher import nebulosa.guiding.Guider import nebulosa.indi.device.camera.Camera @@ -30,7 +30,7 @@ class SequencerExecutor( val sequencerJob = SequencerJob(camera, request, guider, wheel, focuser) sequencerJob.subscribe(messageService::sendMessage) sequencerJob.initialize() - jobExecutions.add(jobLauncher.launch(sequencerJob)) + register(jobLauncher.launch(sequencerJob)) } fun stop(camera: Camera) { diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt index daee1c892..52275437a 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt @@ -1,8 +1,7 @@ package nebulosa.api.wizard.flat -import nebulosa.api.jobs.JobExecutor import nebulosa.api.messages.MessageService -import nebulosa.batch.processing.JobExecution +import nebulosa.batch.processing.JobExecutor import nebulosa.batch.processing.JobLauncher import nebulosa.indi.device.camera.Camera import nebulosa.log.info @@ -17,7 +16,7 @@ class FlatWizardExecutor( fun execute(camera: Camera, request: FlatWizardRequest) { check(camera.connected) { "camera is not connected" } - check(findJobExecution(camera) == null) { "job is already running for camera: [${camera.name}]" } + check(findJobExecutionWithAny(camera) == null) { "job is already running for camera: [${camera.name}]" } LOG.info { "starting flat wizard capture. camera=$camera, request=$request" } @@ -26,19 +25,6 @@ class FlatWizardExecutor( register(jobLauncher.launch(flatWizardJob)) } - fun findJobExecution(camera: Camera): JobExecution? { - for (i in jobExecutions.indices.reversed()) { - val jobExecution = jobExecutions[i] - val job = jobExecution.job as FlatWizardJob - - if (!jobExecution.isDone && job.camera === camera) { - return jobExecution - } - } - - return null - } - fun stop(camera: Camera) { stopWithAny(camera) } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt index 9754b446e..a78f00206 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt @@ -81,9 +81,7 @@ data class FlatWizardStep( this.cameraExposureStep.set(cameraExposureStep) cameraCaptureListeners.forEach(cameraExposureStep::registerCameraCaptureListener) - cameraExposureStep.beforeJob(stepExecution.jobExecution) - cameraExposureStep.execute(stepExecution) - cameraExposureStep.afterJob(stepExecution.jobExecution) + cameraExposureStep.executeSingle(stepExecution) val savedPath = cameraExposureStep.savedPath diff --git a/nebulosa-alignment/build.gradle.kts b/nebulosa-alignment/build.gradle.kts index f8e339788..432cfa8f9 100644 --- a/nebulosa-alignment/build.gradle.kts +++ b/nebulosa-alignment/build.gradle.kts @@ -7,7 +7,6 @@ dependencies { api(project(":nebulosa-erfa")) api(project(":nebulosa-time")) api(project(":nebulosa-plate-solving")) - api(project(":nebulosa-star-detection")) implementation(project(":nebulosa-log")) testImplementation(project(":nebulosa-test")) } diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/PolarErrorDetermination.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/PolarErrorDetermination.kt index 509495166..5e1aef6be 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/PolarErrorDetermination.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/PolarErrorDetermination.kt @@ -1,69 +1,50 @@ package nebulosa.alignment.polar.point.three +import nebulosa.constants.PI +import nebulosa.constants.TAU import nebulosa.math.Angle import nebulosa.math.Vector3D -import nebulosa.plate.solving.PlateSolution +import kotlin.math.abs internal data class PolarErrorDetermination( - @JvmField val initialReferenceFrame: PlateSolution, - @JvmField val firstPosition: Vector3D, - @JvmField val secondPosition: Vector3D, - @JvmField val thirdPosition: Vector3D, - @JvmField val longitude: Angle, @JvmField val latitude: Angle, + @JvmField val firstPosition: Position, + @JvmField val secondPosition: Position, + @JvmField val thirdPosition: Position, + @JvmField val longitude: Angle, + @JvmField val latitude: Angle, ) { - inline val isNorthern + private inline val isNorthern get() = latitude > 0.0 - @JvmField val plane = with(Vector3D.plane(firstPosition, secondPosition, thirdPosition)) { - if (isNorthern && x < 0 || !isNorthern && x > 0) { - // Flip vector if pointing to the wrong direction. - -this + @JvmField val plane = with(Vector3D.plane(firstPosition.vector, secondPosition.vector, thirdPosition.vector)) { + // Flip vector if pointing to the wrong direction. + if (isNorthern && x < 0 || !isNorthern && x > 0) -normalized else normalized + } + + @JvmField val errorPosition = Position(plane, longitude, latitude) + + fun compute(): DoubleArray { + val altitudeError: Double + var azimuthError: Double + + val pole = abs(latitude) + + if (isNorthern) { + altitudeError = errorPosition.topocentric.altitude - pole + azimuthError = errorPosition.topocentric.azimuth } else { - this + altitudeError = pole - errorPosition.topocentric.altitude + azimuthError = errorPosition.topocentric.azimuth + PI } - } - @JvmField val initialMountAxisErrorPosition = Position(plane, longitude, latitude) + if (azimuthError > PI) { + azimuthError -= TAU + } + if (azimuthError < -PI) { + azimuthError += TAU + } -// init { -// calculateMountAxisError() -// } -// -// val isInitialErrorLarge -// get() = initialMountAxisTotalError.Degree > 2 && initialMountAxisTotalError.Degree <= 10 -// -// val isInitialErrorHuge -// get() = initialMountAxisTotalError.Degree > 10 -// -// private fun calculateMountAxisError() { -// val altitudeError: Double -// var azimuthError: Double -// -// val pole = abs(latitude) -// -// if (isNorthern) { -// altitudeError = initialMountAxisErrorPosition.topocentric.altitude - pole -// azimuthError = initialMountAxisErrorPosition.topocentric.azimuth -// } else { -// altitudeError = pole - initialMountAxisErrorPosition.topocentric.altitude -// azimuthError = initialMountAxisErrorPosition.topocentric.azimuth + PI -// } -// -// if (azimuthError > PI) { -// azimuthError -= TAU -// } -// if (azimuthError < -PI) { -// azimuthError += TAU -// } -// -// initialMountAxisAltitudeError = altitudeError -// initialMountAxisAzimuthError = azimuthError -// initialMountAxisTotalError = hypot(initialMountAxisAltitudeError, initialMountAxisAzimuthError) -// -// currentMountAxisAltitudeError = altitudeError -// currentMountAxisAzimuthError = azimuthError -// currentMountAxisTotalError = hypot(initialMountAxisAltitudeError, initialMountAxisAzimuthError) -// currentReferenceFrame = initialReferenceFrame -// } + return doubleArrayOf(azimuthError, altitudeError) + } } diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt index 8178f1e5d..ead777294 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt @@ -1,68 +1,56 @@ package nebulosa.alignment.polar.point.three import nebulosa.constants.PIOVERTWO -import nebulosa.erfa.CartesianCoordinate import nebulosa.erfa.eraAtco13 -import nebulosa.erfa.eraAtic13 -import nebulosa.erfa.eraEe06a import nebulosa.math.Angle +import nebulosa.math.ONE_ATM import nebulosa.math.Vector3D -import nebulosa.math.normalized +import nebulosa.time.CurrentTime import nebulosa.time.IERS -import nebulosa.time.UTC -import kotlin.math.acos - -internal class Position { - - @JvmField val topocentric: Topocentric - @JvmField val vector: Vector3D - - constructor( - rightAscension: Angle, declination: Angle, - longitude: Angle, latitude: Angle, - ) { - val time = UTC.now() - val ee = eraEe06a(time.tt.whole, time.tt.fraction) - val (ri, di) = eraAtic13((rightAscension + ee).normalized, declination, time.tdb.whole, time.tdb.fraction) - val dut1 = IERS.delta(time) - val (xp, yp) = IERS.pmAngles(time) - val (b) = eraAtco13(ri, di, 0.0, 0.0, 0.0, 0.0, time.whole, time.fraction, dut1, longitude, latitude, 0.0, xp, yp, 1013.25, 15.0, 0.0, 0.55) - val az = b[0] // aob - val alt = PIOVERTWO - b[1] // zob - topocentric = Topocentric(az, alt, longitude, latitude, time) - vector = CartesianCoordinate.of(-topocentric.azimuth, PIOVERTWO - topocentric.altitude, 1.0) - } - - constructor(topocentric: Topocentric, vector: Vector3D) { - this.topocentric = topocentric - this.vector = vector - } - - constructor(vector: Vector3D, longitude: Angle, latitude: Angle) { - this.topocentric = if (vector[0] == 0.0 && vector[1] == 0.0) { - Topocentric(0.0, PIOVERTWO, longitude, latitude, UTC.now()) - } else { - Topocentric(-vector.longitude, PIOVERTWO - acos(vector[2]), longitude, latitude, UTC.now()) +import nebulosa.time.InstantOfTime +import kotlin.math.cos +import kotlin.math.sin + +internal data class Position( + @JvmField val topocentric: Topocentric, + @JvmField val vector: Vector3D, +) { + + companion object { + + operator fun invoke( + rightAscension: Angle, declination: Angle, + longitude: Angle, latitude: Angle, + time: InstantOfTime = CurrentTime, + refract: Boolean = false, + ): Position { + // SOFA.CelestialToTopocentric. + val dut1 = IERS.delta(time) + val (xp, yp) = IERS.pmAngles(time) + val pressure = if (refract) ONE_ATM else 0.0 + // @formatter:off + val (b) = eraAtco13(rightAscension, declination, 0.0, 0.0, 0.0, 0.0, time.utc.whole, time.utc.fraction, dut1, longitude, latitude, 0.0, xp, yp, pressure, 15.0, 0.5, 0.55) + // @formatter:on + val topocentric = Topocentric(b[0], PIOVERTWO - b[1], longitude, latitude) + // val vector = CartesianCoordinate.of(-b[0], b[1], 1.0) + val theta = -b[0] + val phi = b[1] + val x = cos(theta) * sin(phi) + val y = sin(theta) * sin(phi) + val z = cos(phi) + return Position(topocentric, Vector3D(x, y, z)) } - this.vector = vector - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is Position) return false + operator fun invoke( + vector: Vector3D, longitude: Angle, latitude: Angle, + ): Position { + val topocentric = if (vector.x == 0.0 && vector.y == 0.0) { + Topocentric(0.0, PIOVERTWO, longitude, latitude) + } else { + Topocentric(-vector.longitude, PIOVERTWO - vector.latitude, longitude, latitude) + } - if (topocentric != other.topocentric) return false - if (vector != other.vector) return false - - return true - } - - override fun hashCode(): Int { - var result = topocentric.hashCode() - result = 31 * result + vector.hashCode() - return result + return Position(topocentric, vector) + } } - - override fun toString() = "Position(topocentric=$topocentric, vector=$vector)" } diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt index 1785e32a3..2dc81380d 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt @@ -1,32 +1,35 @@ package nebulosa.alignment.polar.point.three -import nebulosa.alignment.polar.point.three.ThreePointPolarAlignment.State.* import nebulosa.constants.DEG2RAD import nebulosa.fits.declination +import nebulosa.fits.observationDate import nebulosa.fits.rightAscension import nebulosa.imaging.Image import nebulosa.math.Angle -import nebulosa.math.Point2D import nebulosa.plate.solving.PlateSolution import nebulosa.plate.solving.PlateSolver -import nebulosa.star.detection.StarDetector +import nebulosa.plate.solving.PlateSolvingException +import nebulosa.time.TimeYMDHMS +import nebulosa.time.UTC import java.nio.file.Path - +import kotlin.math.min + +/** + * Three Point Polar Alignment almost anywhere in the sky. + * + * Based on Stefan Berg's algorithm. + * + * @see BitBucket + */ data class ThreePointPolarAlignment( private val solver: PlateSolver, - private val starDetector: StarDetector, + private val longitude: Angle, + private val latitude: Angle, ) { - enum class State { - FIRST_MEASURE, - SECOND_MEASURE, - THIRD_MEASURE, - ADJUSTMENT_MEASURE, - } - - private val solutions = HashMap(4) + private val positions = arrayOfNulls(3) - @Volatile var state = FIRST_MEASURE + @Volatile var state = 0 private set fun align( @@ -35,63 +38,42 @@ data class ThreePointPolarAlignment( declination: Angle = image.header.declination, radius: Angle = DEFAULT_RADIUS, ): ThreePointPolarAlignmentResult { - return when (state) { - FIRST_MEASURE -> measure(path, image, SECOND_MEASURE, rightAscension, declination, radius) - SECOND_MEASURE -> measure(path, image, THIRD_MEASURE, rightAscension, declination, radius) - THIRD_MEASURE -> measure(path, image, ADJUSTMENT_MEASURE, rightAscension, declination, radius) - ADJUSTMENT_MEASURE -> measure(path, image, ADJUSTMENT_MEASURE, rightAscension, declination, radius) + val solution = try { + solver.solve(path, image, rightAscension, declination, radius) + } catch (e: PlateSolvingException) { + return ThreePointPolarAlignmentResult.NoPlateSolution(e) } - } - private fun measure( - path: Path?, image: Image?, nextState: State, - rightAscension: Angle, declination: Angle, radius: Angle, - ): ThreePointPolarAlignmentResult { - val solution = solver.solve(path, image, rightAscension, declination, radius) - - return if (!solution.solved) { - ThreePointPolarAlignmentResult.NoPlateSolution + if (!solution.solved) { + return ThreePointPolarAlignmentResult.NoPlateSolution(null) } else { - solutions[state] = solution + val time = image.header.observationDate?.let { UTC(TimeYMDHMS(it)) } ?: UTC.now() - if (state != ADJUSTMENT_MEASURE) { - state = nextState - ThreePointPolarAlignmentResult.NeedMoreMeasure - } else { - computeAdjustment() + positions[min(state, 2)] = solution.position(time) + + if (state >= 2) { + val polarErrorDetermination = PolarErrorDetermination(positions[0]!!, positions[1]!!, positions[2]!!, longitude, latitude) + val (azimuth, altitude) = polarErrorDetermination.compute() + return ThreePointPolarAlignmentResult.Measured(azimuth, altitude) } + + state++ + + return ThreePointPolarAlignmentResult.NeedMoreMeasurement } } - private fun computeAdjustment(): ThreePointPolarAlignmentResult { - return ThreePointPolarAlignmentResult.NeedMoreMeasure + fun reset() { + state = 0 + positions.fill(null) } - fun selectNewReferenceStar(image: Image, point: Point2D) { - val referenceStar = starDetector.closestStarPosition(image, point) + private fun PlateSolution.position(time: UTC): Position { + return Position(rightAscension, declination, longitude, latitude, time) } companion object { const val DEFAULT_RADIUS: Angle = 4 * DEG2RAD - - @JvmStatic - internal fun StarDetector.closestStarPosition(image: Image, reference: Point2D): Point2D { - val detectedStars = detect(image) - - var closestPoint = reference - var minDistance = Double.MAX_VALUE - - for (star in detectedStars) { - val distance = star.distance(reference) - - if (distance < minDistance) { - closestPoint = star - minDistance = distance - } - } - - return closestPoint - } } } diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt index 7dd272e64..9ad931a53 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt @@ -1,8 +1,13 @@ package nebulosa.alignment.polar.point.three +import nebulosa.math.Angle +import nebulosa.plate.solving.PlateSolvingException + sealed interface ThreePointPolarAlignmentResult { - data object NeedMoreMeasure : ThreePointPolarAlignmentResult + data object NeedMoreMeasurement : ThreePointPolarAlignmentResult + + data class Measured(@JvmField val azimuth: Angle, @JvmField val altitude: Angle) : ThreePointPolarAlignmentResult - data object NoPlateSolution : ThreePointPolarAlignmentResult + data class NoPlateSolution(@JvmField val exception: PlateSolvingException?) : ThreePointPolarAlignmentResult } diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Topocentric.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Topocentric.kt index b996328e7..ec1ca0366 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Topocentric.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Topocentric.kt @@ -1,10 +1,8 @@ package nebulosa.alignment.polar.point.three import nebulosa.math.Angle -import nebulosa.time.UTC data class Topocentric( @JvmField val azimuth: Angle, @JvmField val altitude: Angle, @JvmField val longitude: Angle, @JvmField val latitude: Angle, - @JvmField val time: UTC, ) diff --git a/nebulosa-alignment/src/test/kotlin/ThreePointPolarAlignmentTest.kt b/nebulosa-alignment/src/test/kotlin/ThreePointPolarAlignmentTest.kt new file mode 100644 index 000000000..5d4f8eb01 --- /dev/null +++ b/nebulosa-alignment/src/test/kotlin/ThreePointPolarAlignmentTest.kt @@ -0,0 +1,103 @@ +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.doubles.plusOrMinus +import io.kotest.matchers.shouldBe +import nebulosa.alignment.polar.point.three.PolarErrorDetermination +import nebulosa.alignment.polar.point.three.Position +import nebulosa.math.deg +import nebulosa.math.hours +import nebulosa.math.toArcsec +import nebulosa.time.IERS +import nebulosa.time.IERSA +import nebulosa.time.TimeYMDHMS +import nebulosa.time.UTC +import java.nio.file.Path +import kotlin.io.path.inputStream + +class ThreePointPolarAlignmentTest : StringSpec() { + + init { + val iersa = IERSA() + iersa.load(Path.of("../data/finals2000A.all").inputStream()) + IERS.attach(iersa) + + // Based on logs generated by N.I.N.A. using Telescope Simulator for .NET and Sky Simulator (ASCOM). + // https://sourceforge.net/projects/sky-simulator/ + + "perfectly aligned" { + val position1 = Position("05:35:18".hours, "-05 23 26".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 10, 22, 58, 42.4979))) + position1.vector[0] shouldBe (0.301851589038 plusOrMinus 1e-4) + position1.vector[1] shouldBe (-0.0681426041296783 plusOrMinus 1e-4) + position1.vector[2] shouldBe (0.950916507216938 plusOrMinus 1e-4) + + val position2 = Position("04:54:45".hours, "-05 24 50".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 10, 22, 58, 58.1655))) + position2.vector[0] shouldBe (0.300426130373811 plusOrMinus 1e-4) + position2.vector[1] shouldBe (0.108903442814494 plusOrMinus 1e-4) + position2.vector[2] shouldBe (0.947567507005051 plusOrMinus 1e-4) + + val position3 = Position("04:14:08".hours, "-05 26 10".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 10, 22, 59, 13.3739))) + position3.vector[0] shouldBe (0.286747300379159 plusOrMinus 1e-4) + position3.vector[1] shouldBe (0.282671401982864 plusOrMinus 1e-4) + position3.vector[2] shouldBe (0.915353955705828 plusOrMinus 1e-4) + + val pe = PolarErrorDetermination(position1, position2, position3, LNG, SLAT) + val (az, alt) = pe.compute() + + // Calculated Error: Az: -00° 00' 04", Alt: -00° 00' 07", Tot: 00° 00' 08" + az.toArcsec shouldBe (-4.0 plusOrMinus 2.5) + alt.toArcsec shouldBe (-7.0 plusOrMinus 2.5) + } + "bad southern polar aligned" { + val position1 = Position("05:35:29".hours, "-05 23 44".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 11, 1, 2, 28.0693))) + position1.vector[0] shouldBe (0.260120895582042 plusOrMinus 1e-4) + position1.vector[1] shouldBe (0.452793316696993 plusOrMinus 1e-4) + position1.vector[2] shouldBe (0.852827844313337 plusOrMinus 1e-4) + + val position2 = Position("04:54:48".hours, "-05 23 16".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 11, 1, 2, 43.0120))) + position2.vector[0] shouldBe (0.223679240068826 plusOrMinus 1e-4) + position2.vector[1] shouldBe (0.603179137475884 plusOrMinus 1e-4) + position2.vector[2] shouldBe (0.765599455117414 plusOrMinus 1e-4) + + val position3 = Position("04:14:05".hours, "-05 22 47".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 11, 1, 2, 57.8800))) + position3.vector[0] shouldBe (0.177343985686423 plusOrMinus 1e-4) + position3.vector[1] shouldBe (0.734426214459154 plusOrMinus 1e-4) + position3.vector[2] shouldBe (0.65510857592925 plusOrMinus 1e-4) + + val pe = PolarErrorDetermination(position1, position2, position3, LNG, SLAT) + val (az, alt) = pe.compute() + + // Calculated Error: Az: 00° 10' 10", Alt: 00° 04' 41", Tot: 00° 11' 11" + az.toArcsec shouldBe (610.0 plusOrMinus 7.0) + alt.toArcsec shouldBe (281.0 plusOrMinus 7.0) + } + "bad northern polar aligned" { + val position1 = Position("05:35:35".hours, "-05 32 31".deg, LNG, NLAT, UTC(TimeYMDHMS(2024, 2, 11, 1, 19, 31.1390))) + position1.vector[0] shouldBe (-0.420977957462894 plusOrMinus 1e-4) + position1.vector[1] shouldBe (0.517127315719859 plusOrMinus 1e-4) + position1.vector[2] shouldBe (0.745222717492391 plusOrMinus 1e-4) + + val position2 = Position("04:54:49".hours, "-05 34 43".deg, LNG, NLAT, UTC(TimeYMDHMS(2024, 2, 11, 1, 19, 46.2383))) + position2.vector[0] shouldBe (-0.379893065189774 plusOrMinus 1e-4) + position2.vector[1] shouldBe (0.660293278184844 plusOrMinus 1e-4) + position2.vector[2] shouldBe (0.647837978050554 plusOrMinus 1e-4) + + val position3 = Position("04:13:55".hours, "-05 36 32".deg, LNG, NLAT, UTC(TimeYMDHMS(2024, 2, 11, 1, 20, 1.6394))) + position3.vector[0] shouldBe (-0.329258727296886 plusOrMinus 1e-4) + position3.vector[1] shouldBe (0.782693400663722 plusOrMinus 1e-4) + position3.vector[2] shouldBe (0.528185318857211 plusOrMinus 1e-4) + + val pe = PolarErrorDetermination(position1, position2, position3, LNG, NLAT) + val (az, alt) = pe.compute() + + // Calculated Error: Az: -00° 09' 58", Alt: 00° 04' 51", Tot: 00° 11' 05" + az.toArcsec shouldBe (-598.0 plusOrMinus 7.0) + alt.toArcsec shouldBe (291.0 plusOrMinus 7.0) + } + } + + companion object { + + @JvmStatic private val SLAT = "-023".deg + @JvmStatic private val NLAT = "+023".deg + @JvmStatic private val LNG = "-045".deg + } +} diff --git a/api/src/main/kotlin/nebulosa/api/jobs/JobExecutor.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecutor.kt similarity index 91% rename from api/src/main/kotlin/nebulosa/api/jobs/JobExecutor.kt rename to nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecutor.kt index d7ca06aa7..1bc04063b 100644 --- a/api/src/main/kotlin/nebulosa/api/jobs/JobExecutor.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecutor.kt @@ -1,8 +1,5 @@ -package nebulosa.api.jobs +package nebulosa.batch.processing -import nebulosa.batch.processing.Job -import nebulosa.batch.processing.JobExecution -import nebulosa.batch.processing.JobLauncher import java.util.* abstract class JobExecutor { diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Step.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Step.kt index fe2d7d914..ab62c8cb3 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Step.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Step.kt @@ -4,5 +4,15 @@ interface Step : Stoppable, JobExecutionListener { fun execute(stepExecution: StepExecution): StepResult + fun executeSingle(stepExecution: StepExecution): StepResult { + beforeJob(stepExecution.jobExecution) + + try { + return execute(stepExecution) + } finally { + afterJob(stepExecution.jobExecution) + } + } + override fun stop(mayInterruptIfRunning: Boolean) = Unit } diff --git a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt index 5d3001a9d..4876436ba 100644 --- a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt +++ b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt @@ -1056,8 +1056,7 @@ fun eraS06a(tt1: Double, tt2: Double): Angle { // Bias-precession-nutation-matrix, IAU 20006/2000A. val rnpb = eraPnm06a(tt1, tt2) // Extract the CIP coordinates. - val x = rnpb[2, 0] - val y = rnpb[2, 1] + val (x, y) = eraBpn2xy(rnpb) // Compute the CIO locator s, given the CIP coordinates. return eraS06(tt1, tt2, x, y) } @@ -1086,6 +1085,8 @@ fun era00(ut11: Double, ut12: Double): Angle { * @return tan Z coefficient (radians) and tan^3 Z coefficient (radians) */ fun eraRefco(phpa: Pressure, tc: Temperature, rh: Double, wl: Double): DoubleArray { + if (phpa <= 0.0) return doubleArrayOf(0.0, 0.0) + // Decide whether optical/IR or radio case: switch at 100 microns. val optic = wl <= 100.0 @@ -1095,12 +1096,8 @@ fun eraRefco(phpa: Pressure, tc: Temperature, rh: Double, wl: Double): DoubleArr val w = max(0.1, min(wl, 1e+6)) // Water vapour pressure at the observer. - val pw = if (p > 0.0) { - val ps = 10.0.pow((0.7859 + 0.03477 * t) / (1.0 + 0.00412 * t)) * (1.0 + p * (4.5e-6 + 6e-10 * t * t)) - r * ps / (1.0 - (1.0 - r) * ps / p) - } else { - 0.0 - } + val ps = 10.0.pow((0.7859 + 0.03477 * t) / (1.0 + 0.00412 * t)) * (1.0 + p * (4.5e-6 + 6e-10 * t * t)) + val pw = r * ps / (1.0 - (1.0 - r) * ps / p) // Refractive index minus 1 at the observer. diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt index bd7c9fa8e..9d1deb9fc 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt @@ -328,13 +328,13 @@ internal open class MountDevice( override fun toString(): String { return "Mount(name=$name, connected=$connected, slewing=$slewing, tracking=$tracking," + - " parking=$parking, parked=$parked, canAbort=$canAbort," + - " canSync=$canSync, canPark=$canPark, slewRates=$slewRates," + - " slewRate=$slewRate, mountType=$mountType, trackModes=$trackModes," + - " trackMode=$trackMode, pierSide=$pierSide, guideRateWE=$guideRateWE," + - " guideRateNS=$guideRateNS, rightAscension=$rightAscension," + - " declination=$declination, canPulseGuide=$canPulseGuide," + - " pulseGuiding=$pulseGuiding)" + " parking=$parking, parked=$parked, canAbort=$canAbort," + + " canSync=$canSync, canPark=$canPark, slewRates=$slewRates," + + " slewRate=$slewRate, mountType=$mountType, trackModes=$trackModes," + + " trackMode=$trackMode, pierSide=$pierSide, guideRateWE=$guideRateWE," + + " guideRateNS=$guideRateNS, rightAscension=$rightAscension," + + " declination=$declination, canPulseGuide=$canPulseGuide," + + " pulseGuiding=$pulseGuiding)" } companion object { diff --git a/nebulosa-math/src/main/kotlin/nebulosa/math/Angle.kt b/nebulosa-math/src/main/kotlin/nebulosa/math/Angle.kt index c78a1106e..f1ecf6057 100644 --- a/nebulosa-math/src/main/kotlin/nebulosa/math/Angle.kt +++ b/nebulosa-math/src/main/kotlin/nebulosa/math/Angle.kt @@ -110,6 +110,12 @@ fun Angle.dms(): DoubleArray { inline fun Angle.format(formatter: AngleFormatter) = formatter.format(this) +inline fun Angle.formatHMS() = format(AngleFormatter.HMS) + +inline fun Angle.formatDMS() = format(AngleFormatter.DMS) + +inline fun Angle.formatSignedDMS() = format(AngleFormatter.SIGNED_DMS) + inline fun HMS(hour: Int, minute: Int, second: Double = 0.0) = (hour + minute / 60.0 + second / 3600.0).hours inline fun DMS(degrees: Int, minute: Int, second: Double = 0.0, negative: Boolean = degrees < 0) = diff --git a/nebulosa-math/src/main/kotlin/nebulosa/math/Pressure.kt b/nebulosa-math/src/main/kotlin/nebulosa/math/Pressure.kt index 901314ddd..fa24cd4c5 100644 --- a/nebulosa-math/src/main/kotlin/nebulosa/math/Pressure.kt +++ b/nebulosa-math/src/main/kotlin/nebulosa/math/Pressure.kt @@ -47,7 +47,7 @@ inline val Int.atm * * https://www.mide.com/air-pressure-at-altitude-calculator */ -fun Distance.pressure(temperature: Temperature = 10.0.celsius): Pressure { +fun Distance.pressure(temperature: Temperature = 15.0.celsius): Pressure { val e = 9.80665 * 0.0289644 / (8.31432 * -0.0065) val k = temperature.toKelvin val m = toMeters diff --git a/nebulosa-math/src/main/kotlin/nebulosa/math/Vector3D.kt b/nebulosa-math/src/main/kotlin/nebulosa/math/Vector3D.kt index 30ded1004..b641ed4ed 100644 --- a/nebulosa-math/src/main/kotlin/nebulosa/math/Vector3D.kt +++ b/nebulosa-math/src/main/kotlin/nebulosa/math/Vector3D.kt @@ -1,6 +1,9 @@ package nebulosa.math -import kotlin.math.* +import kotlin.math.abs +import kotlin.math.acos +import kotlin.math.atan2 +import kotlin.math.sqrt @Suppress("NOTHING_TO_INLINE") open class Vector3D protected constructor(@PublishedApi @JvmField internal val vector: DoubleArray) : Point3D, Cloneable { @@ -60,7 +63,7 @@ open class Vector3D protected constructor(@PublishedApi @JvmField internal val v get() = length.let { if (it == 0.0) this else this / it } inline val latitude - get() = asin(vector[2] / length).rad + get() = acos(vector[2]).rad inline val longitude get() = atan2(vector[1], vector[0]).rad.normalized @@ -132,7 +135,7 @@ open class Vector3D protected constructor(@PublishedApi @JvmField internal val v */ @JvmStatic fun plane(a: Vector3D, b: Vector3D, c: Vector3D): Vector3D { - return (b - a).cross(c - b).normalized + return (b - a).cross(c - b) } } } diff --git a/nebulosa-math/src/test/kotlin/AngleTest.kt b/nebulosa-math/src/test/kotlin/AngleTest.kt index bb7dc62cf..e648f136d 100644 --- a/nebulosa-math/src/test/kotlin/AngleTest.kt +++ b/nebulosa-math/src/test/kotlin/AngleTest.kt @@ -164,15 +164,11 @@ class AngleTest : StringSpec() { .build() .format(negativeAngle) shouldBe "-043 0000 45.00000" - AngleFormatter.HMS - .format(0.0) shouldBe "00h00m00.0s" - - AngleFormatter.HMS - .format(CIRCLE) shouldBe "00h00m00.0s" + AngleFormatter.HMS.format(0.0) shouldBe "00h00m00.0s" + AngleFormatter.HMS.format(CIRCLE) shouldBe "00h00m00.0s" } "bug on round seconds" { - "23h59m60.0s".hours - .format(AngleFormatter.HMS) shouldBe "00h00m00.0s" + "23h59m60.0s".hours.formatHMS() shouldBe "00h00m00.0s" AngleFormatter.HMS .format(Radians(6.283182643402501)) shouldBe "23h59m59.9s" diff --git a/nebulosa-math/src/test/kotlin/Vector3DTest.kt b/nebulosa-math/src/test/kotlin/Vector3DTest.kt index 326a0c7be..bb9e95324 100644 --- a/nebulosa-math/src/test/kotlin/Vector3DTest.kt +++ b/nebulosa-math/src/test/kotlin/Vector3DTest.kt @@ -76,5 +76,14 @@ class Vector3DTest : StringSpec() { "no rotation" { Vector3D(1.0, 2.0, 3.0).rotate(Vector3D.Y, 0.0) shouldBe Vector3D(1.0, 2.0, 3.0) } + "plane" { + val a = Vector3D(1.0, -2.0, 1.0) + val b = Vector3D(4.0, -2.0, -2.0) + val c = Vector3D(4.0, 1.0, 4.0) + val d = Vector3D.plane(a, b, c) + d.x shouldBeExactly 9.0 + d.y shouldBeExactly -18.0 + d.z shouldBeExactly 9.0 + } } } diff --git a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/Barycentric.kt b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/Barycentric.kt index 3820a035f..d325f7421 100644 --- a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/Barycentric.kt +++ b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/Barycentric.kt @@ -8,7 +8,7 @@ import nebulosa.nova.astrometry.Body import nebulosa.nova.astrometry.Observable import nebulosa.nova.frame.Ecliptic import nebulosa.time.InstantOfTime -import nebulosa.time.TimeJD +import nebulosa.time.TT /** * An |xyz| position measured from the Solar System barycenter. @@ -45,7 +45,7 @@ class Barycentric internal constructor( */ fun phaseAngle(target: Body, center: Body): Angle { val pe = -observe(target) // Rotate 180 degrees to point back at Earth. - val ps = target.at(TimeJD(time.tt - pe.lightTime)).observe(center) + val ps = target.at(TT(time.tt.whole - pe.lightTime, time.tt.fraction)).observe(center) return pe.separationFrom(ps) } diff --git a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/GeographicPosition.kt b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/GeographicPosition.kt index 6dc0be571..5d6a1e3c6 100644 --- a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/GeographicPosition.kt +++ b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/GeographicPosition.kt @@ -2,14 +2,17 @@ package nebulosa.nova.position import nebulosa.constants.ANGULAR_VELOCITY import nebulosa.constants.DAYSEC -import nebulosa.constants.DEG2RAD +import nebulosa.constants.PIOVERTWO +import nebulosa.erfa.eraRefco import nebulosa.erfa.eraSp00 import nebulosa.math.* import nebulosa.nova.frame.Frame import nebulosa.nova.frame.ITRS import nebulosa.time.IERS import nebulosa.time.InstantOfTime +import kotlin.math.abs import kotlin.math.atan2 +import kotlin.math.pow import kotlin.math.tan class GeographicPosition( @@ -50,19 +53,11 @@ class GeographicPosition( */ fun refract( altitude: Angle, - temperature: Temperature = 10.0.celsius, + temperature: Temperature = 15.0.celsius, pressure: Pressure = elevation.pressure(temperature), - ): Angle { - val a = altitude.toDegrees - - return if (a >= -1.0 && a <= 89.9) { - val r = 0.016667 / tan((a + 7.31 / (a + 4.4)) * DEG2RAD) - val d = r * (0.28 * pressure / temperature.toKelvin) - (a + d).deg - } else { - altitude - } - } + relativeHumidity: Double = 0.0, + waveLength: Double = 0.54, + ) = computeRefractedAltitude(altitude, temperature, pressure, relativeHumidity, waveLength) /** * Computes rotation from GCRS to this location’s altazimuth system. @@ -116,5 +111,47 @@ class GeographicPosition( companion object { @JvmStatic val EARTH_ANGULAR_VELOCITY_VECTOR = Vector3D(z = DAYSEC * ANGULAR_VELOCITY) + + @JvmStatic + fun computeRefractedAltitude( + altitude: Angle, + temperature: Temperature = 15.0.celsius, + pressure: Pressure = ONE_ATM, + relativeHumidity: Double = 0.5, + waveLength: Double = 0.55, + iterationIncrement: Angle = 1.0.arcsec, + ): Double { + if (altitude < 0.0) { + return altitude + } + + val z = PIOVERTWO - altitude + + val (refa, refb) = eraRefco(pressure, temperature, relativeHumidity, waveLength) + + var roller = iterationIncrement + var iterations = 0 + + while (iterations++ < 10) { + val refractedZenithDistanceRadian = z - roller + + // dZ = A tan Z + B tan^3 Z. + val dZ2 = refa * tan(refractedZenithDistanceRadian) + refb * tan(refractedZenithDistanceRadian).pow(3.0) + + if (dZ2.isNaN()) { + return altitude + } + + val originalZenithDistanceRadian = refractedZenithDistanceRadian + dZ2 + + if (abs(originalZenithDistanceRadian - z) < iterationIncrement) { + return PIOVERTWO - originalZenithDistanceRadian + } + + roller += iterationIncrement + } + + return altitude + } } } diff --git a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt index e37ea0bf0..3981f03e0 100644 --- a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt +++ b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt @@ -9,9 +9,8 @@ import nebulosa.math.* import nebulosa.nova.astrometry.Body import nebulosa.nova.frame.Frame import nebulosa.nova.frame.ITRS +import nebulosa.time.CurrentTime import nebulosa.time.InstantOfTime -import nebulosa.time.TimeJD -import nebulosa.time.UTC import kotlin.math.atan2 /** @@ -78,23 +77,24 @@ open class ICRF protected constructor( /** * Computes the equatorial (RA, declination, distance) * referenced to the dynamical system defined by - * the Earth's true equator and equinox at specific [epoch] time. + * the Earth's true equator and equinox at specific time + * represented by its rotation [matrix]. */ - fun equatorialAtEpoch(epoch: InstantOfTime) = (epoch.m * position).let { SphericalCoordinate.of(it[0].au, it[1].au, it[2].au) } + fun equatorialAtEpoch(matrix: Matrix3D) = (matrix * position).let { SphericalCoordinate.of(it[0].au, it[1].au, it[2].au) } /** * Computes the equatorial (RA, declination, distance) * referenced to the dynamical system defined by - * the Earth's true equator and equinox of [time]. + * the Earth's true equator and equinox at specific [epoch] time. */ - fun equatorialAtDate() = equatorialAtEpoch(time) + fun equatorialAtEpoch(epoch: InstantOfTime) = equatorialAtEpoch(epoch.m) /** * Computes the equatorial (RA, declination, distance) * referenced to the dynamical system defined by - * the Earth's true equator and equinox of J2000.0. + * the Earth's true equator and equinox of [time]. */ - fun equatorialJ2000() = equatorialAtEpoch(TimeJD.J2000) + fun equatorialAtDate() = equatorialAtEpoch(time) /** * Computes hour angle, declination, and distance. @@ -125,7 +125,7 @@ open class ICRF protected constructor( * Computes the altitude, azimuth and distance relative to the observer's horizon. */ fun horizontal( - temperature: Temperature = 10.0.celsius, + temperature: Temperature = 15.0.celsius, pressure: Pressure = ONE_ATM, ): SphericalCoordinate { // TODO: Uncomment when implement apparent method. @@ -214,8 +214,8 @@ open class ICRF protected constructor( val horizontalRotation by lazy { require(target is GeographicPosition || target is PlanetograhicPosition) { "to compute an altazimuth position, you must observe from " + - "a specific Earth location or from a position on another body loaded from a set " + - "of planetary constants" + "a specific Earth location or from a position on another body loaded from a set " + + "of planetary constants" } (target as Frame).rotationAt(time) @@ -242,26 +242,21 @@ open class ICRF protected constructor( else -> ICRF(position, velocity, time, center, target) } + @JvmStatic internal fun horizontal( position: ICRF, - temperature: Temperature = 10.0.celsius, - pressure: Pressure = 1013.0.mbar, + temperature: Temperature = 15.0.celsius, + pressure: Pressure = ONE_ATM, ): SphericalCoordinate { - val centerBarycentric = position.centerBarycentric - - val r = centerBarycentric?.horizontalRotation - ?: if (position.center is Frame) { - position.center.rotationAt(position.time) - } else { - throw IllegalArgumentException( - "to compute an altazimuth position, you must observe from " + - "a specific Earth location or from a position on another body loaded from a set " + - "of planetary constants" - ) - } - - val h = r * position.position - val coordinate = SphericalCoordinate.of(h[0].au, h[1].au, h[2].au) + val r = position.centerBarycentric?.horizontalRotation + ?: (position.center as? Frame)?.rotationAt(position.time) + ?: throw IllegalArgumentException( + "to compute an altazimuth position, you must observe from " + + "a specific Earth location or from a position on another body loaded from a set " + + "of planetary constants" + ) + + val coordinate = SphericalCoordinate.of(r * position.position) return if (position.center is GeographicPosition) { val refracted = position.center.refract(coordinate.latitude, temperature, pressure) @@ -274,6 +269,7 @@ open class ICRF protected constructor( /** * Builds a position from two vectors in a reference [frame] at the [time]. */ + @JvmStatic fun frame( time: InstantOfTime, frame: Frame, @@ -309,10 +305,11 @@ open class ICRF protected constructor( * to be in the dynamical system of that particular date. Otherwise, * they will be assumed to be ICRS (the modern replacement for J2000). */ + @JvmStatic fun equatorial( rightAscension: Angle, declination: Angle, distance: Distance = ONE_GIGAPARSEC, - time: InstantOfTime = UTC.now(), + time: InstantOfTime = CurrentTime, epoch: InstantOfTime? = null, center: Number = Int.MIN_VALUE, target: Number = Int.MIN_VALUE, diff --git a/nebulosa-nova/src/test/kotlin/GeographicPositionTest.kt b/nebulosa-nova/src/test/kotlin/GeographicPositionTest.kt index 0b25b2b39..30a74a811 100644 --- a/nebulosa-nova/src/test/kotlin/GeographicPositionTest.kt +++ b/nebulosa-nova/src/test/kotlin/GeographicPositionTest.kt @@ -1,9 +1,8 @@ import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.doubles.plusOrMinus import io.kotest.matchers.shouldBe -import nebulosa.math.AngleFormatter import nebulosa.math.deg -import nebulosa.math.format +import nebulosa.math.formatHMS import nebulosa.math.m import nebulosa.nova.position.Geoid import nebulosa.time.IERS @@ -22,9 +21,9 @@ class GeographicPositionTest : StringSpec() { "lst" { val position = Geoid.IERS2010.lonLat((-45.4227).deg, 0.0) - position.lstAt(UTC(TimeYMDHMS(2022, 1, 1, 12, 0, 0.0))).format(AngleFormatter.HMS) shouldBe "15h42m47.1s" - position.lstAt(UTC(TimeYMDHMS(2024, 1, 1, 12, 0, 0.0))).format(AngleFormatter.HMS) shouldBe "15h40m53.1s" - position.lstAt(UTC(TimeYMDHMS(2025, 1, 1, 12, 0, 0.0))).format(AngleFormatter.HMS) shouldBe "15h43m52.8s" + position.lstAt(UTC(TimeYMDHMS(2022, 1, 1, 12, 0, 0.0))).formatHMS() shouldBe "15h42m47.1s" + position.lstAt(UTC(TimeYMDHMS(2024, 1, 1, 12, 0, 0.0))).formatHMS() shouldBe "15h40m53.1s" + position.lstAt(UTC(TimeYMDHMS(2025, 1, 1, 12, 0, 0.0))).formatHMS() shouldBe "15h43m52.8s" } "xyz" { val latitude = "-23 32 51.00".deg diff --git a/nebulosa-nova/src/test/kotlin/ICRFTest.kt b/nebulosa-nova/src/test/kotlin/ICRFTest.kt index 9df910256..ba3229db0 100644 --- a/nebulosa-nova/src/test/kotlin/ICRFTest.kt +++ b/nebulosa-nova/src/test/kotlin/ICRFTest.kt @@ -4,10 +4,7 @@ import io.kotest.matchers.shouldBe import nebulosa.math.* import nebulosa.nova.position.Geoid import nebulosa.nova.position.ICRF -import nebulosa.time.IERS -import nebulosa.time.IERSA -import nebulosa.time.TimeJD -import nebulosa.time.TimeYMDHMS +import nebulosa.time.* import java.nio.file.Path import kotlin.io.path.inputStream @@ -21,16 +18,16 @@ class ICRFTest : StringSpec() { "equatorial at date to equatorial J2000" { val ra = 2.15105.deg val dec = (-0.4493).deg - val (raNow, decNow) = ICRF.equatorial(ra, dec, epoch = TimeJD(2459950.24436)).equatorialJ2000() - raNow.toDegrees shouldBe (1.85881 plusOrMinus 1e-2) - decNow.toDegrees shouldBe (-0.5762 plusOrMinus 1e-2) + val (raNow, decNow) = ICRF.equatorial(ra, dec, epoch = TT(2459950.0, 0.24436)).equatorial() + raNow.toDegrees shouldBe (1.85881 plusOrMinus 1e-4) + decNow.toDegrees shouldBe (-0.5762 plusOrMinus 1e-4) } "equatorial J2000 to equatorial at date" { val ra = 1.85881.deg val dec = (-0.5762).deg - val (raNow, decNow) = ICRF.equatorial(ra, dec).equatorialAtEpoch(TimeJD(2459950.24436)) - raNow.toDegrees shouldBe (2.15105 plusOrMinus 1e-2) - decNow.toDegrees shouldBe (-0.4493 plusOrMinus 1e-2) + val (raNow, decNow) = ICRF.equatorial(ra, dec).equatorialAtEpoch(TT(2459950.0, 0.24436)) + raNow.toDegrees shouldBe (2.15105 plusOrMinus 1e-4) + decNow.toDegrees shouldBe (-0.4493 plusOrMinus 1e-4) } "horizontal" { // Sirius. diff --git a/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolution.kt b/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolution.kt index 9c42cfff8..e092bc4c3 100644 --- a/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolution.kt +++ b/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolution.kt @@ -47,8 +47,8 @@ data class PlateSolution( LOG.info( "solution from {}: ORIE={}, SCALE={}, RA={}, DEC={}", - header, crota2.format(AngleFormatter.SIGNED_DMS), cdelt2.toArcsec, - crval1.format(AngleFormatter.HMS), crval2.format(AngleFormatter.SIGNED_DMS), + header, crota2.formatSignedDMS(), cdelt2.toArcsec, + crval1.formatHMS(), crval2.formatSignedDMS(), ) return PlateSolution(true, crota2, cdelt2, crval1, crval2, abs(cdelt1 * width), abs(cdelt2 * height), header = header) diff --git a/nebulosa-plate-solving/src/test/kotlin/PlateSolutionTest.kt b/nebulosa-plate-solving/src/test/kotlin/PlateSolutionTest.kt index d940a1ae1..635c3341c 100644 --- a/nebulosa-plate-solving/src/test/kotlin/PlateSolutionTest.kt +++ b/nebulosa-plate-solving/src/test/kotlin/PlateSolutionTest.kt @@ -3,8 +3,8 @@ import io.kotest.matchers.doubles.plusOrMinus import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import nebulosa.fits.Header -import nebulosa.math.AngleFormatter -import nebulosa.math.format +import nebulosa.math.formatHMS +import nebulosa.math.formatSignedDMS import nebulosa.math.toArcsec import nebulosa.math.toDegrees import nebulosa.plate.solving.PlateSolution @@ -41,8 +41,8 @@ class PlateSolutionTest : StringSpec() { val header = Header.from(astrometryNet) val solution = PlateSolution.from(header).shouldNotBeNull() - solution.rightAscension.format(AngleFormatter.HMS) shouldBe "03h19m07.7s" - solution.declination.format(AngleFormatter.SIGNED_DMS) shouldBe "-066°30'12.2\"" + solution.rightAscension.formatHMS() shouldBe "03h19m07.7s" + solution.declination.formatSignedDMS() shouldBe "-066°30'12.2\"" solution.orientation.toDegrees shouldBe (-136.9 plusOrMinus 1e-1) solution.scale.toArcsec shouldBe (1.37 plusOrMinus 1e-2) solution.radius.toDegrees shouldBe (0.476 plusOrMinus 1e-3) diff --git a/nebulosa-simbad/src/main/kotlin/nebulosa/simbad/SimbadService.kt b/nebulosa-simbad/src/main/kotlin/nebulosa/simbad/SimbadService.kt index 7a089c55a..6cbd5aed0 100644 --- a/nebulosa-simbad/src/main/kotlin/nebulosa/simbad/SimbadService.kt +++ b/nebulosa-simbad/src/main/kotlin/nebulosa/simbad/SimbadService.kt @@ -10,7 +10,6 @@ import nebulosa.retrofit.CSVRecordListConverterFactory import nebulosa.retrofit.RetrofitService import nebulosa.skycatalog.SkyObject import nebulosa.skycatalog.SkyObjectType -import nebulosa.time.UTC import okhttp3.FormBody import okhttp3.OkHttpClient import retrofit2.Call @@ -52,7 +51,6 @@ class SimbadService( fun search(query: Query): List { val rows = query(query).execute().body() ?: return emptyList() val res = ArrayList() - val currentTime = UTC.now() fun matchName(name: String): String? { for (type in SimbadCatalogType.entries) { @@ -93,7 +91,7 @@ class SimbadService( } val distance = SkyObject.distanceFor(parallax) - val constellation = SkyObject.constellationFor(rightAscensionJ2000, declinationJ2000, currentTime) + val constellation = SkyObject.constellationFor(rightAscensionJ2000, declinationJ2000) val entity = SimbadEntry( id, name.joinToString("") { "[$it]" }, magnitude, diff --git a/nebulosa-skycatalog-hyg/src/main/kotlin/nebulosa/skycatalog/hyg/HygDatabase.kt b/nebulosa-skycatalog-hyg/src/main/kotlin/nebulosa/skycatalog/hyg/HygDatabase.kt index f60ba0852..98cb77fbf 100644 --- a/nebulosa-skycatalog-hyg/src/main/kotlin/nebulosa/skycatalog/hyg/HygDatabase.kt +++ b/nebulosa-skycatalog-hyg/src/main/kotlin/nebulosa/skycatalog/hyg/HygDatabase.kt @@ -10,7 +10,6 @@ import nebulosa.nova.astrometry.Constellation import nebulosa.skycatalog.SkyCatalog import nebulosa.skycatalog.SkyObject import nebulosa.skycatalog.SkyObject.Companion.NAME_SEPARATOR -import nebulosa.time.UTC import java.io.InputStream import java.io.InputStreamReader @@ -27,7 +26,6 @@ class HygDatabase : SkyCatalog(118005) { val reader = CSV_READER.ofNamedCsvRecord(InputStreamReader(stream, Charsets.UTF_8)) val names = ArrayList(7) - val currentTime = UTC.now() for (record in reader) { val id = record.getField("id").toLong() @@ -51,7 +49,7 @@ class HygDatabase : SkyCatalog(118005) { .takeIf { it.isNotEmpty() } ?.uppercase() ?.let(Constellation::valueOf) - ?: SkyObject.constellationFor(rightAscension, declination, currentTime) + ?: SkyObject.constellationFor(rightAscension, declination) names.clear() diff --git a/nebulosa-skycatalog-stellarium/src/main/kotlin/nebulosa/skycatalog/stellarium/Nebula.kt b/nebulosa-skycatalog-stellarium/src/main/kotlin/nebulosa/skycatalog/stellarium/Nebula.kt index 8aace1d6b..039d8d543 100644 --- a/nebulosa-skycatalog-stellarium/src/main/kotlin/nebulosa/skycatalog/stellarium/Nebula.kt +++ b/nebulosa-skycatalog-stellarium/src/main/kotlin/nebulosa/skycatalog/stellarium/Nebula.kt @@ -7,7 +7,6 @@ import nebulosa.math.rad import nebulosa.skycatalog.SkyCatalog import nebulosa.skycatalog.SkyObject import nebulosa.skycatalog.SkyObject.Companion.NAME_SEPARATOR -import nebulosa.time.UTC import okio.BufferedSource import okio.Source import okio.buffer @@ -25,7 +24,6 @@ class Nebula : SkyCatalog(94661) { source.readString() // Version. source.readString() // Edition. - val currentTime = UTC.now() val commonNames = namesSource?.let(::namesFor) ?: emptyList() val names = ArrayList(8) @@ -122,7 +120,7 @@ class Nebula : SkyCatalog(94661) { majorAxis, minorAxis, orientation, parallax = parallax, redshift = redshift, // distance * 3261.5637769, - constellation = SkyObject.constellationFor(ra, dec, currentTime), + constellation = SkyObject.constellationFor(ra, dec), ) add(nebula) diff --git a/nebulosa-skycatalog/src/main/kotlin/nebulosa/skycatalog/SkyObject.kt b/nebulosa-skycatalog/src/main/kotlin/nebulosa/skycatalog/SkyObject.kt index de49c236a..124dc2a4f 100644 --- a/nebulosa-skycatalog/src/main/kotlin/nebulosa/skycatalog/SkyObject.kt +++ b/nebulosa-skycatalog/src/main/kotlin/nebulosa/skycatalog/SkyObject.kt @@ -5,7 +5,7 @@ import nebulosa.math.Distance import nebulosa.math.ONE_PARSEC import nebulosa.nova.astrometry.Constellation import nebulosa.nova.position.ICRF -import nebulosa.time.UTC +import nebulosa.time.InstantOfTime interface SkyObject { @@ -29,8 +29,13 @@ interface SkyObject { @JvmStatic val MAGNITUDE_RANGE = MAGNITUDE_MIN..MAGNITUDE_MAX @JvmStatic - fun constellationFor(rightAscension: Angle, declination: Angle, time: UTC): Constellation { - return Constellation.find(ICRF.equatorial(rightAscension, declination, time = time)) + fun constellationFor(icrf: ICRF): Constellation { + return Constellation.find(icrf) + } + + @JvmStatic + fun constellationFor(rightAscension: Angle, declination: Angle, epoch: InstantOfTime? = null): Constellation { + return constellationFor(ICRF.equatorial(rightAscension, declination, epoch = epoch)) } @JvmStatic diff --git a/nebulosa-time/src/main/kotlin/nebulosa/time/CurrentTime.kt b/nebulosa-time/src/main/kotlin/nebulosa/time/CurrentTime.kt index 076b9afa3..206e6bbaa 100644 --- a/nebulosa-time/src/main/kotlin/nebulosa/time/CurrentTime.kt +++ b/nebulosa-time/src/main/kotlin/nebulosa/time/CurrentTime.kt @@ -4,9 +4,8 @@ import nebulosa.common.time.Stopwatch object CurrentTime : InstantOfTime() { - const val MAX_INTERVAL_KEY = "CURRENT_TIME.MAX_INTERVAL" + const val ELAPSED_INTERVAL = 1000L - private val maxInterval = System.getProperty(MAX_INTERVAL_KEY, "30000").toLongOrNull() ?: 30000L private val stopwatch = Stopwatch() init { @@ -16,7 +15,7 @@ object CurrentTime : InstantOfTime() { private var time = UTC.now() get() { synchronized(stopwatch) { - if (stopwatch.elapsedMilliseconds >= maxInterval) { + if (stopwatch.elapsedMilliseconds >= ELAPSED_INTERVAL) { stopwatch.reset() field = UTC.now() } diff --git a/nebulosa-time/src/main/kotlin/nebulosa/time/TT.kt b/nebulosa-time/src/main/kotlin/nebulosa/time/TT.kt index 0a404d2a2..e66983337 100644 --- a/nebulosa-time/src/main/kotlin/nebulosa/time/TT.kt +++ b/nebulosa-time/src/main/kotlin/nebulosa/time/TT.kt @@ -33,4 +33,14 @@ class TT : TimeJD, Timescale { override val tdb by lazy { TDB(eraTtTdb(whole, fraction, TDBMinusTT.delta(this)), true) } override val tcb get() = tdb.tcb + + companion object { + + @JvmStatic val J2000 = TT(TimeJD.J2000) + + @JvmStatic val B1950 = TT(TimeJD.B1950) + + @JvmStatic + fun now() = TT(TimeJD.now()) + } } diff --git a/nebulosa-time/src/main/kotlin/nebulosa/time/TimeJD.kt b/nebulosa-time/src/main/kotlin/nebulosa/time/TimeJD.kt index a400cc98c..be0c7ff97 100644 --- a/nebulosa-time/src/main/kotlin/nebulosa/time/TimeJD.kt +++ b/nebulosa-time/src/main/kotlin/nebulosa/time/TimeJD.kt @@ -30,19 +30,19 @@ open class TimeJD internal constructor(private val jd: DoubleArray, normalize: B override fun minus(delta: TimeDelta) = TimeJD(whole, fraction - delta.delta(this)) - override val ut1 get() = UT1(whole, fraction) + override val ut1 by lazy { UT1(whole, fraction) } - override val utc get() = UTC(whole, fraction) + override val utc by lazy { UTC(whole, fraction) } - override val tai get() = TAI(whole, fraction) + override val tai by lazy { TAI(whole, fraction) } - override val tt get() = TT(whole, fraction) + override val tt by lazy { TT(whole, fraction) } - override val tcg get() = TCG(whole, fraction) + override val tcg by lazy { TCG(whole, fraction) } - override val tdb get() = TDB(whole, fraction) + override val tdb by lazy { TDB(whole, fraction) } - override val tcb get() = TCB(whole, fraction) + override val tcb by lazy { TCB(whole, fraction) } companion object { diff --git a/nebulosa-time/src/main/kotlin/nebulosa/time/UTC.kt b/nebulosa-time/src/main/kotlin/nebulosa/time/UTC.kt index 1b0e67435..8d9122513 100644 --- a/nebulosa-time/src/main/kotlin/nebulosa/time/UTC.kt +++ b/nebulosa-time/src/main/kotlin/nebulosa/time/UTC.kt @@ -35,6 +35,10 @@ class UTC : TimeJD, Timescale { companion object { + @JvmStatic val J2000 = UTC(TimeJD.J2000) + + @JvmStatic val B1950 = UTC(TimeJD.B1950) + @JvmStatic fun now() = UTC(TimeJD.now()) } diff --git a/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt b/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt index 5877ccef7..5fbdd8df7 100644 --- a/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt +++ b/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt @@ -4,8 +4,8 @@ import io.kotest.matchers.doubles.plusOrMinus import io.kotest.matchers.shouldBe import nebulosa.fits.Fits import nebulosa.fits.Header -import nebulosa.math.AngleFormatter -import nebulosa.math.format +import nebulosa.math.formatHMS +import nebulosa.math.formatSignedDMS import nebulosa.test.NonGitHubOnlyCondition import nebulosa.wcs.WCS import kotlin.random.Random @@ -37,11 +37,11 @@ class LibWCSTest : StringSpec() { val bottomRight = it.pixToSky(width.toDouble(), height.toDouble()) val center = it.pixToSky(width / 2.0, height / 2.0) - println("top left: ${topLeft.rightAscension.format(AngleFormatter.HMS)} ${topLeft.declination.format(AngleFormatter.SIGNED_DMS)}") - println("top right: ${topRight.rightAscension.format(AngleFormatter.HMS)} ${topRight.declination.format(AngleFormatter.SIGNED_DMS)}") - println("bottom left: ${bottomLeft.rightAscension.format(AngleFormatter.HMS)} ${bottomLeft.declination.format(AngleFormatter.SIGNED_DMS)}") - println("bottom right: ${bottomRight.rightAscension.format(AngleFormatter.HMS)} ${bottomRight.declination.format(AngleFormatter.SIGNED_DMS)}") - println("center: ${center.rightAscension.format(AngleFormatter.HMS)} ${center.declination.format(AngleFormatter.SIGNED_DMS)}") + println("top left: ${topLeft.rightAscension.formatHMS()} ${topLeft.declination.formatSignedDMS()}") + println("top right: ${topRight.rightAscension.formatHMS()} ${topRight.declination.formatSignedDMS()}") + println("bottom left: ${bottomLeft.rightAscension.formatHMS()} ${bottomLeft.declination.formatSignedDMS()}") + println("bottom right: ${bottomRight.rightAscension.formatHMS()} ${bottomRight.declination.formatSignedDMS()}") + println("center: ${center.rightAscension.formatHMS()} ${center.declination.formatSignedDMS()}") for ((x0, y0) in data) { val (rightAscension, declination) = it.pixToSky(x0.toDouble(), y0.toDouble()) From c39354fd7a067415927a231352a4bfcaf0ec717c Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sun, 11 Feb 2024 19:53:30 -0300 Subject: [PATCH 09/87] [api][desktop]: Implement Three Point Polar Alignment --- .../api/alignment/polar/darv/DARVJob.kt | 8 +- .../alignment/polar/darv/DARVStartRequest.kt | 10 +- .../api/alignment/polar/tppa/TPPAEvent.kt | 101 +++++++ .../api/alignment/polar/tppa/TPPAExecutor.kt | 13 +- .../api/alignment/polar/tppa/TPPAJob.kt | 27 +- .../api/alignment/polar/tppa/TPPAListener.kt | 18 ++ .../alignment/polar/tppa/TPPAStartRequest.kt | 1 + .../api/alignment/polar/tppa/TPPAState.kt | 10 + .../api/alignment/polar/tppa/TPPAStep.kt | 73 ++++- .../app/alignment/alignment.component.html | 96 ++++-- .../src/app/alignment/alignment.component.ts | 278 +++++++++++++----- desktop/src/app/atlas/atlas.component.html | 15 +- .../calibration/calibration.component.html | 11 +- desktop/src/app/camera/camera.component.html | 24 +- desktop/src/app/camera/camera.component.ts | 96 +++--- .../filterwheel/filterwheel.component.html | 4 +- .../app/filterwheel/filterwheel.component.ts | 8 +- .../flat-wizard/flat-wizard.component.html | 26 +- .../app/flat-wizard/flat-wizard.component.ts | 46 ++- desktop/src/app/focuser/focuser.component.ts | 2 +- .../src/app/framing/framing.component.html | 2 +- desktop/src/app/guider/guider.component.html | 6 +- desktop/src/app/image/image.component.html | 26 +- desktop/src/app/image/image.component.ts | 33 +-- desktop/src/app/indi/indi.component.html | 2 +- .../property/indi-property.component.html | 11 +- desktop/src/app/mount/mount.component.html | 10 +- desktop/src/app/mount/mount.component.ts | 2 +- .../app/sequencer/sequencer.component.html | 14 +- .../src/app/settings/settings.component.ts | 23 +- .../dialogs/location/location.dialog.html | 2 +- desktop/src/shared/pipes/enum.pipe.ts | 22 +- desktop/src/shared/services/api.service.ts | 12 +- .../shared/services/browser-window.service.ts | 5 +- .../src/shared/services/electron.service.ts | 3 +- .../src/shared/services/preference.service.ts | 59 +++- desktop/src/shared/types/alignment.types.ts | 54 +++- desktop/src/shared/types/camera.types.ts | 36 ++- desktop/src/shared/types/image.types.ts | 18 ++ desktop/src/shared/types/settings.types.ts | 4 +- .../point/three/ThreePointPolarAlignment.kt | 12 +- .../three/ThreePointPolarAlignmentResult.kt | 7 +- .../astap/plate/solving/AstapPlateSolver.kt | 5 +- .../solving/LibAstrometryNetPlateSolver.kt | 2 + .../solving/LocalAstrometryNetPlateSolver.kt | 4 +- .../solving/NovaAstrometryNetPlateSolver.kt | 12 +- .../common/process/ProcessExecutor.kt | 5 +- nebulosa-plate-solving/build.gradle.kts | 1 + .../nebulosa/plate/solving/PlateSolver.kt | 2 + .../watney/plate/solving/WatneyPlateSolver.kt | 2 + 50 files changed, 888 insertions(+), 375 deletions(-) create mode 100644 api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt create mode 100644 api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAListener.kt create mode 100644 api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAState.kt diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt index 4ea3a5b95..47dbe0e1d 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt @@ -29,7 +29,7 @@ data class DARVJob( @JvmField val direction = if (request.reversed) request.direction.reversed else request.direction @JvmField val cameraRequest = request.capture.copy( - exposureTime = request.exposureTime + request.initialPause, + exposureTime = request.capture.exposureTime + request.capture.exposureDelay, savePath = Files.createTempDirectory("darv"), exposureAmount = 1, exposureDelay = Duration.ZERO, frameType = FrameType.LIGHT, autoSave = false, autoSubFolderMode = AutoSubFolderMode.OFF @@ -41,10 +41,10 @@ data class DARVJob( val cameraExposureStep = CameraExposureStep(camera, cameraRequest) cameraExposureStep.registerCameraCaptureListener(this) - val initialPauseDelayStep = DelayStep(request.initialPause) + val initialPauseDelayStep = DelayStep(request.capture.exposureDelay) initialPauseDelayStep.registerDelayStepListener(this) - val guidePulseDuration = request.exposureTime.dividedBy(2L) + val guidePulseDuration = request.capture.exposureTime.dividedBy(2L) val forwardGuidePulseRequest = GuidePulseRequest(direction, guidePulseDuration) val forwardGuidePulseStep = GuidePulseStep(guideOutput, forwardGuidePulseRequest) forwardGuidePulseStep.registerGuidePulseListener(this) @@ -58,7 +58,7 @@ data class DARVJob( } override fun beforeJob(jobExecution: JobExecution) { - onNext(DARVStarted(camera, guideOutput, request.initialPause, direction)) + onNext(DARVStarted(camera, guideOutput, request.capture.exposureDelay, direction)) } override fun afterJob(jobExecution: JobExecution) { diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt index 5dcd20b5a..53b99114f 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt @@ -1,18 +1,10 @@ package nebulosa.api.alignment.polar.darv -import com.fasterxml.jackson.annotation.JsonIgnoreProperties import nebulosa.api.cameras.CameraStartCaptureRequest import nebulosa.guiding.GuideDirection -import org.hibernate.validator.constraints.time.DurationMax -import org.hibernate.validator.constraints.time.DurationMin -import org.springframework.boot.convert.DurationUnit -import java.time.Duration -import java.time.temporal.ChronoUnit data class DARVStartRequest( - @JsonIgnoreProperties("camera", "focuser", "wheel") val capture: CameraStartCaptureRequest = CameraStartCaptureRequest.EMPTY, - @field:DurationMin(seconds = 1) @field:DurationMax(seconds = 600) @field:DurationUnit(ChronoUnit.SECONDS) val exposureTime: Duration = Duration.ZERO, - @field:DurationMin(seconds = 1) @field:DurationMax(seconds = 60) @field:DurationUnit(ChronoUnit.SECONDS) val initialPause: Duration = Duration.ZERO, + val capture: CameraStartCaptureRequest = CameraStartCaptureRequest.EMPTY, val direction: GuideDirection = GuideDirection.NORTH, val reversed: Boolean = false, ) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt new file mode 100644 index 000000000..7d3dd31bf --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt @@ -0,0 +1,101 @@ +package nebulosa.api.alignment.polar.tppa + +import nebulosa.api.messages.MessageEvent +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.mount.Mount +import nebulosa.math.Angle +import java.time.Duration + +sealed interface TPPAEvent : MessageEvent { + + val camera: Camera + + val mount: Mount? + + val state: TPPAState + + val stepCount: Int + + val elapsedTime: Duration + + val rightAscension: Angle + get() = 0.0 + + val declination: Angle + get() = 0.0 + + val azimuth: Angle + get() = 0.0 + + val altitude: Angle + get() = 0.0 + + override val eventName + get() = "TPPA_ALIGNMENT.ELAPSED" + + data class Slewing( + override val camera: Camera, + override val mount: Mount?, + override val stepCount: Int, + override val elapsedTime: Duration, + override val rightAscension: Angle, + override val declination: Angle, + ) : TPPAEvent { + + override val state = TPPAState.SLEWING + } + + data class Solving( + override val camera: Camera, + override val mount: Mount?, + override val stepCount: Int, + override val elapsedTime: Duration, + ) : TPPAEvent { + + override val state = TPPAState.SOLVING + } + + data class Solved( + override val camera: Camera, + override val mount: Mount?, + override val stepCount: Int, + override val elapsedTime: Duration, + override val rightAscension: Angle, + override val declination: Angle, + ) : TPPAEvent { + + override val state = TPPAState.SOLVED + } + + data class Computed( + override val camera: Camera, + override val mount: Mount?, + override val stepCount: Int, + override val elapsedTime: Duration, + override val azimuth: Double, + override val altitude: Double, + ) : TPPAEvent { + + override val state = TPPAState.COMPUTED + } + + data class Failed( + override val camera: Camera, + override val mount: Mount?, + override val stepCount: Int, + override val elapsedTime: Duration, + ) : TPPAEvent { + + override val state = TPPAState.FAILED + } + + data class Finished( + override val camera: Camera, + override val mount: Mount?, + ) : TPPAEvent { + + override val stepCount = 0 + override val elapsedTime: Duration = Duration.ZERO + override val state = TPPAState.FINISHED + } +} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt index 94a83866a..425aafcaa 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt @@ -1,5 +1,8 @@ package nebulosa.api.alignment.polar.tppa +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.cameras.CameraExposureFinished +import nebulosa.api.messages.MessageEvent import nebulosa.api.messages.MessageService import nebulosa.api.solver.PlateSolverService import nebulosa.batch.processing.JobExecutor @@ -15,7 +18,7 @@ class TPPAExecutor( override val jobLauncher: JobLauncher, private val messageService: MessageService, private val plateSolverService: PlateSolverService, -) : JobExecutor() { +) : JobExecutor(), Consumer { @Synchronized fun execute(camera: Camera, mount: Mount, request: TPPAStartRequest): String { @@ -26,7 +29,7 @@ class TPPAExecutor( val solver = plateSolverService.solverFor(request.plateSolver) return with(TPPAJob(camera, request, solver, mount)) { - subscribe(messageService::sendMessage) + subscribe(this@TPPAExecutor) register(jobLauncher.launch(this)) id } @@ -36,6 +39,12 @@ class TPPAExecutor( stopWithAny(camera, mount) } + override fun accept(event: MessageEvent) { + if (event is TPPAEvent || event is CameraExposureFinished) { + messageService.sendMessage(event) + } + } + companion object { @JvmStatic private val LOG = loggerFor() diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt index e94627779..58e4ca851 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt @@ -22,7 +22,7 @@ data class TPPAJob( @JvmField val mount: Mount? = null, @JvmField val longitude: Angle = mount!!.longitude, @JvmField val latitude: Angle = mount!!.latitude, -) : SimpleJob(), PublishSubscribe, CameraCaptureListener { +) : SimpleJob(), PublishSubscribe, CameraCaptureListener, TPPAListener { @JvmField val cameraRequest = request.capture.copy( savePath = Files.createTempDirectory("tppa"), @@ -37,10 +37,35 @@ data class TPPAJob( init { tppaStep.registerCameraCaptureListener(cameraCaptureEventHandler) + tppaStep.registerTPPAListener(this) register(tppaStep) } + override fun slewStarted(step: TPPAStep, rightAscension: Angle, declination: Angle) { + onNext(TPPAEvent.Slewing(step.camera, step.mount, step.stepCount, step.elapsedTime, rightAscension, declination)) + } + + override fun solverStarted(step: TPPAStep) { + onNext(TPPAEvent.Solving(step.camera, step.mount, step.stepCount, step.elapsedTime)) + } + + override fun solverFinished(step: TPPAStep, rightAscension: Angle, declination: Angle) { + onNext(TPPAEvent.Solved(step.camera, step.mount, step.stepCount, step.elapsedTime, rightAscension, declination)) + } + + override fun polarAlignmentComputed(step: TPPAStep, azimuth: Angle, altitude: Angle) { + onNext(TPPAEvent.Computed(step.camera, step.mount, step.stepCount, step.elapsedTime, azimuth, altitude)) + } + + override fun solverFailed(step: TPPAStep) { + onNext(TPPAEvent.Failed(step.camera, step.mount, step.stepCount, step.elapsedTime)) + } + + override fun polarAlignmentFinished(step: TPPAStep, aborted: Boolean) { + onNext(TPPAEvent.Finished(step.camera, step.mount)) + } + override fun contains(data: Any): Boolean { return data === camera || data === mount || super.contains(data) } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAListener.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAListener.kt new file mode 100644 index 000000000..04996ba03 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAListener.kt @@ -0,0 +1,18 @@ +package nebulosa.api.alignment.polar.tppa + +import nebulosa.math.Angle + +interface TPPAListener { + + fun slewStarted(step: TPPAStep, rightAscension: Angle, declination: Angle) + + fun solverStarted(step: TPPAStep) + + fun solverFinished(step: TPPAStep, rightAscension: Angle, declination: Angle) + + fun polarAlignmentComputed(step: TPPAStep, azimuth: Angle, altitude: Angle) + + fun solverFailed(step: TPPAStep) + + fun polarAlignmentFinished(step: TPPAStep, aborted: Boolean) +} 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 db1a82df7..8989610db 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 @@ -13,4 +13,5 @@ data class TPPAStartRequest( val eastDirection: Boolean = true, val refractionAdjustment: Boolean = false, val stopTrackingWhenDone: Boolean = true, + val stepDistance: Double = 10.0, // degrees ) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAState.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAState.kt new file mode 100644 index 000000000..67e933b8b --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAState.kt @@ -0,0 +1,10 @@ +package nebulosa.api.alignment.polar.tppa + +enum class TPPAState { + SLEWING, + SOLVING, + SOLVED, + COMPUTED, + FAILED, + FINISHED, +} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt index 037e8eed3..eb1ead59c 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt @@ -10,6 +10,8 @@ import nebulosa.batch.processing.JobExecution import nebulosa.batch.processing.Step import nebulosa.batch.processing.StepExecution import nebulosa.batch.processing.StepResult +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.common.time.Stopwatch import nebulosa.fits.Fits import nebulosa.imaging.Image import nebulosa.indi.device.camera.Camera @@ -21,10 +23,10 @@ import nebulosa.math.deg import nebulosa.plate.solving.PlateSolver data class TPPAStep( - private val camera: Camera, + @JvmField val camera: Camera, private val solver: PlateSolver, private val request: TPPAStartRequest, - private val mount: Mount? = null, + @JvmField val mount: Mount? = null, private val longitude: Angle = mount!!.longitude, private val latitude: Angle = mount!!.latitude, private val cameraRequest: CameraStartCaptureRequest = request.capture, @@ -32,11 +34,20 @@ data class TPPAStep( private val cameraExposureStep = CameraExposureStep(camera, cameraRequest) private val alignment = ThreePointPolarAlignment(solver, longitude, latitude) + private val listeners = LinkedHashSet() + private val stopwatch = Stopwatch() + private val cancellationToken = CancellationToken() + @Volatile private var image: Image? = null @Volatile private var mountSlewStep: MountSlewStep? = null - @Volatile private var stopped = false @Volatile private var noSolutionAttempts = 0 + val stepCount + get() = alignment.state + + val elapsedTime + get() = stopwatch.elapsed + fun registerCameraCaptureListener(listener: CameraCaptureListener): Boolean { return cameraExposureStep.registerCameraCaptureListener(listener) } @@ -45,6 +56,14 @@ data class TPPAStep( return cameraExposureStep.unregisterCameraCaptureListener(listener) } + fun registerTPPAListener(listener: TPPAListener): Boolean { + return listeners.add(listener) + } + + fun unregisterTPPAListener(listener: TPPAListener): Boolean { + return listeners.remove(listener) + } + override fun beforeJob(jobExecution: JobExecution) { cameraExposureStep.beforeJob(jobExecution) mount?.tracking(true) @@ -56,26 +75,35 @@ data class TPPAStep( if (mount != null && request.stopTrackingWhenDone) { mount.tracking(false) } + + stopwatch.stop() + + listeners.forEach { it.polarAlignmentFinished(this, cancellationToken.isCancelled) } } override fun execute(stepExecution: StepExecution): StepResult { - if (stopped) return StepResult.FINISHED + if (cancellationToken.isCancelled) return StepResult.FINISHED LOG.debug { "executing TPPA. camera=$camera, mount=$mount, state=${alignment.state}" } + stopwatch.start() + if (mount != null) { if (alignment.state in 1..2) { - val step = MountSlewStep(mount, mount.rightAscension + 10.deg, mount.declination) + val step = MountSlewStep(mount, mount.rightAscension + request.stepDistance.deg, mount.declination) mountSlewStep = step + listeners.forEach { it.slewStarted(this, step.rightAscension, step.declination) } step.executeSingle(stepExecution) } } - if (stopped) return StepResult.FINISHED + if (cancellationToken.isCancelled) return StepResult.FINISHED + + listeners.forEach { it.solverStarted(this) } cameraExposureStep.execute(stepExecution) - if (!stopped) { + if (!cancellationToken.isCancelled) { val savedPath = cameraExposureStep.savedPath ?: return StepResult.FINISHED image = Fits(savedPath).also(Fits::read).use { image?.load(it, false) ?: Image.open(it, false) } val radius = if (mount == null) 0.0 else ThreePointPolarAlignment.DEFAULT_RADIUS @@ -83,13 +111,28 @@ data class TPPAStep( LOG.info("alignment completed. result=$result") - if (result is ThreePointPolarAlignmentResult.NeedMoreMeasurement) { - noSolutionAttempts = 0 - return StepResult.CONTINUABLE - } else if (result is ThreePointPolarAlignmentResult.NoPlateSolution) { - noSolutionAttempts++ - return if (noSolutionAttempts < 10) StepResult.CONTINUABLE - else StepResult.FINISHED + when (result) { + is ThreePointPolarAlignmentResult.NeedMoreMeasurement -> { + noSolutionAttempts = 0 + listeners.forEach { it.solverFinished(this, result.rightAscension, result.declination) } + return StepResult.CONTINUABLE + } + is ThreePointPolarAlignmentResult.NoPlateSolution -> { + noSolutionAttempts++ + + return if (noSolutionAttempts < 10) { + listeners.forEach { it.solverFailed(this) } + StepResult.CONTINUABLE + } else { + StepResult.FINISHED + } + } + is ThreePointPolarAlignmentResult.Measured -> { + listeners.forEach { + it.solverFinished(this, result.rightAscension, result.declination) + it.polarAlignmentComputed(this, result.azimuth, result.altitude) + } + } } } @@ -97,7 +140,7 @@ data class TPPAStep( } override fun stop(mayInterruptIfRunning: Boolean) { - stopped = true + cancellationToken.cancel(mayInterruptIfRunning) mountSlewStep?.stop(mayInterruptIfRunning) cameraExposureStep.stop(mayInterruptIfRunning) } diff --git a/desktop/src/app/alignment/alignment.component.html b/desktop/src/app/alignment/alignment.component.html index d67825797..674bac16c 100644 --- a/desktop/src/app/alignment/alignment.component.html +++ b/desktop/src/app/alignment/alignment.component.html @@ -2,31 +2,39 @@

- + - +
+
+ + + + + -
-
+
- - -
- {{ darvStatus | enum | lowercase }} - + {{ status | enum | lowercase }} + {{ darvDirection }} @@ -41,20 +49,60 @@
- - + + +
+
+ + + + +
+
+ + + + +
+
+ East + +
+
+ +
+
+ +
+
+
+ + + +
+
+
+ +
- +
- +
@@ -66,14 +114,14 @@
-
+
- - - + + +
diff --git a/desktop/src/app/alignment/alignment.component.ts b/desktop/src/app/alignment/alignment.component.ts index 7a2c04920..8a294d889 100644 --- a/desktop/src/app/alignment/alignment.component.ts +++ b/desktop/src/app/alignment/alignment.component.ts @@ -2,11 +2,14 @@ import { AfterViewInit, Component, HostListener, NgZone, OnDestroy } from '@angu import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' import { ElectronService } from '../../shared/services/electron.service' -import { LocalStorageService } from '../../shared/services/local-storage.service' -import { DARVStart, DARVState, Hemisphere } from '../../shared/types/alignment.types' -import { Camera, CameraPreference, CameraStartCapture, cameraPreferenceKey } from '../../shared/types/camera.types' -import { GuideDirection, GuideOutput } from '../../shared/types/guider.types' +import { PreferenceService } from '../../shared/services/preference.service' +import { AlignmentMethod, AlignmentPreference, DARVStart, DARVState, Hemisphere, TPPAStart, TPPAState } from '../../shared/types/alignment.types' +import { Camera, EMPTY_CAMERA, EMPTY_CAMERA_START_CAPTURE, ExposureTimeUnit } 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 { DEFAULT_SOLVER_TYPES, EMPTY_PLATE_SOLVER_OPTIONS, PlateSolverType } from '../../shared/types/settings.types' import { AppComponent } from '../app.component' +import { CameraComponent } from '../camera/camera.component' @Component({ selector: 'app-alignment', @@ -16,20 +19,44 @@ import { AppComponent } from '../app.component' export class AlignmentComponent implements AfterViewInit, OnDestroy { cameras: Camera[] = [] - camera?: Camera - cameraConnected = false + camera = Object.assign({}, EMPTY_CAMERA) + + mounts: Mount[] = [] + mount = Object.assign({}, EMPTY_MOUNT) guideOutputs: GuideOutput[] = [] - guideOutput?: GuideOutput - guideOutputConnected = false + guideOutput = Object.assign({}, EMPTY_GUIDE_OUTPUT) + + tab = 0 + + running = false + alignmentMethod?: AlignmentMethod + status: DARVState | TPPAState = 'IDLE' + + readonly tppaRequest: TPPAStart = { + capture: Object.assign({}, EMPTY_CAMERA_START_CAPTURE), + plateSolver: Object.assign({}, EMPTY_PLATE_SOLVER_OPTIONS), + startFromCurrentPosition: true, + eastDirection: true, + refractionAdjustment: true, + stopTrackingWhenDone: true, + stepDistance: 10, + } - darvInitialPause = 5 - darvDrift = 30 - darvInProgress = false + readonly plateSolverTypes: PlateSolverType[] = Object.assign([], DEFAULT_SOLVER_TYPES) + + readonly darvRequest: DARVStart = { + capture: Object.assign({}, EMPTY_CAMERA_START_CAPTURE), + initialPause: 5, + exposureTime: 30, + direction: 'NORTH', + reversed: false + } + + readonly driftExposureUnit = ExposureTimeUnit.SECOND readonly darvHemispheres: Hemisphere[] = ['NORTHERN', 'SOUTHERN'] darvHemisphere: Hemisphere = 'NORTHERN' darvDirection?: GuideDirection - darvStatus: DARVState | 'IDLE' = 'IDLE' darvRemainingTime = 0 darvProgress = 0 @@ -37,17 +64,16 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { app: AppComponent, private api: ApiService, private browserWindow: BrowserWindowService, - private storage: LocalStorageService, + private preference: PreferenceService, electron: ElectronService, ngZone: NgZone, ) { app.title = 'Alignment' electron.on('CAMERA.UPDATED', event => { - if (event.device.name === this.camera?.name) { + if (event.device.name === this.camera.name) { ngZone.run(() => { - Object.assign(this.camera!, event.device) - this.updateCamera() + Object.assign(this.camera, event.device) }) } }) @@ -64,8 +90,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { if (index >= 0) { if (this.cameras[index] === this.camera) { - this.camera = undefined - this.cameraConnected = false + Object.assign(this.camera, EMPTY_CAMERA) } this.cameras.splice(index, 1) @@ -73,6 +98,42 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { }) }) + electron.on('MOUNT.UPDATED', event => { + if (event.device.name === this.mount.name) { + ngZone.run(() => { + Object.assign(this.mount, event.device) + }) + } + }) + + electron.on('MOUNT.ATTACHED', event => { + ngZone.run(() => { + this.mounts.push(event.device) + }) + }) + + electron.on('MOUNT.DETACHED', event => { + ngZone.run(() => { + const index = this.mounts.findIndex(e => e.name === event.device.name) + + if (index >= 0) { + if (this.mounts[index] === this.mount) { + Object.assign(this.mount, EMPTY_MOUNT) + } + + this.mounts.splice(index, 1) + } + }) + }) + + electron.on('GUIDE_OUTPUT.UPDATED', event => { + if (event.device.name === this.guideOutput.name) { + ngZone.run(() => { + Object.assign(this.guideOutput, event.device) + }) + } + }) + electron.on('GUIDE_OUTPUT.ATTACHED', event => { ngZone.run(() => { this.guideOutputs.push(event.device) @@ -85,8 +146,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { if (index >= 0) { if (this.guideOutputs[index] === this.guideOutput) { - this.guideOutput = undefined - this.guideOutputConnected = false + Object.assign(this.guideOutput, EMPTY_GUIDE_OUTPUT) } this.guideOutputs.splice(index, 1) @@ -94,29 +154,38 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { }) }) - electron.on('GUIDE_OUTPUT.UPDATED', event => { - if (event.device.name === this.guideOutput?.name) { + electron.on('TPPA_ALIGNMENT.ELAPSED', event => { + if (event.camera.name === this.camera.name && + (!event.mount || event.mount.name === this.mount.name)) { ngZone.run(() => { - Object.assign(this.guideOutput!, event.device) - this.updateGuideOutput() + this.status = event.state + this.running = event.state !== 'FINISHED' + + if (!this.running) { + this.alignmentMethod = undefined + } }) } }) electron.on('DARV_ALIGNMENT.ELAPSED', event => { - if (event.camera.name === this.camera?.name && - event.guideOutput.name === this.guideOutput?.name) { + if (event.camera.name === this.camera.name && + event.guideOutput.name === this.guideOutput.name) { ngZone.run(() => { - this.darvStatus = event.state + this.status = event.state this.darvRemainingTime = event.remainingTime this.darvProgress = event.progress - this.darvInProgress = event.remainingTime > 0 + this.running = event.remainingTime > 0 if (event.state === 'FORWARD' || event.state === 'BACKWARD') { this.darvDirection = event.direction } else { this.darvDirection = undefined } + + if (!this.running) { + this.alignmentMethod = undefined + } }) } }) @@ -124,99 +193,154 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { async ngAfterViewInit() { this.cameras = await this.api.cameras() + this.mounts = await this.api.mounts() this.guideOutputs = await this.api.guideOutputs() + this.loadPreference() } @HostListener('window:unload') ngOnDestroy() { this.darvStop() + this.tppaStop() } async cameraChanged() { - if (this.camera) { + if (this.camera.name) { const camera = await this.api.camera(this.camera.name) Object.assign(this.camera, camera) + this.loadPreference() + } + } - this.updateCamera() + async mountChanged() { + if (this.mount.name) { + const mount = await this.api.mount(this.mount.name) + Object.assign(this.mount, mount) } } async guideOutputChanged() { - if (this.guideOutput) { + if (this.guideOutput.name) { const guideOutput = await this.api.guideOutput(this.guideOutput.name) Object.assign(this.guideOutput, guideOutput) - - this.updateGuideOutput() } } - cameraConnect() { - if (this.cameraConnected) { - this.api.cameraDisconnect(this.camera!) - } else { - this.api.cameraConnect(this.camera!) + mountConnect() { + if (this.mount.name) { + if (this.mount.connected) { + this.api.mountDisconnect(this.mount) + } else { + this.api.mountConnect(this.mount) + } } } guideOutputConnect() { - if (this.guideOutputConnected) { - this.api.guideOutputDisconnect(this.guideOutput!) - } else { - this.api.guideOutputConnect(this.guideOutput!) + if (this.guideOutput.name) { + if (this.guideOutput.connected) { + this.api.guideOutputDisconnect(this.guideOutput) + } else { + this.api.guideOutputConnect(this.guideOutput) + } } } + async showCameraDialog() { + if (this.camera.name) { + if (this.tab === 0 && await CameraComponent.showAsDialog(this.browserWindow, 'TPPA', this.camera, this.tppaRequest.capture)) { + this.savePreference() + } else if (this.tab === 1 && await CameraComponent.showAsDialog(this.browserWindow, 'DARV', this.camera, this.darvRequest.capture)) { + this.savePreference() + this.darvRequest.exposureTime = this.darvRequest.capture.exposureTime / 1000000 + this.darvRequest.initialPause = this.darvRequest.capture.exposureDelay + } + } + } + + plateSolverChanged() { + this.tppaRequest.plateSolver = this.preference.plateSolverOptions(this.tppaRequest.plateSolver.type).get() + this.savePreference() + } + + initialPauseChanged() { + this.darvRequest.capture.exposureDelay = this.darvRequest.initialPause + this.savePreference() + } + + driftForChanged() { + this.darvRequest.capture.exposureTime = this.darvRequest.exposureTime * 1000000 + this.savePreference() + } + async darvStart(direction: GuideDirection = 'EAST') { - const reversed = this.darvHemisphere === 'SOUTHERN' + this.alignmentMethod = 'DARV' + this.darvRequest.direction = direction + this.darvRequest.reversed = this.darvHemisphere === 'SOUTHERN' + this.darvRequest.capture.exposureTime = this.darvRequest.exposureTime * 1000000 + this.darvRequest.capture.exposureDelay = this.darvRequest.initialPause await this.openCameraImage() - const capture = this.makeCameraStartCapture(this.camera!) - const data: DARVStart = { capture, exposureTime: this.darvDrift, initialPause: this.darvInitialPause, direction, reversed } - await this.api.darvStart(this.camera!, this.guideOutput!, data) + await this.api.darvStart(this.camera, this.guideOutput, this.darvRequest) } darvStop() { - this.api.darvStop(this.camera!, this.guideOutput!) + this.api.darvStop(this.camera, this.guideOutput) + } + + async tppaStart() { + this.alignmentMethod = 'TPPA' + await this.openCameraImage() + await this.api.tppaStart(this.camera, this.mount, this.tppaRequest) + } + + tppaStop() { + this.api.tppaStop(this.camera, this.mount) } openCameraImage() { - return this.browserWindow.openCameraImage(this.camera!) + return this.browserWindow.openCameraImage(this.camera) } - private updateCamera() { - if (!this.camera) { - return + private loadPreference() { + const preference = this.preference.alignmentPreference().get() + + this.tppaRequest.startFromCurrentPosition = preference.tppaStartFromCurrentPosition + this.tppaRequest.eastDirection = preference.tppaEastDirection + this.tppaRequest.refractionAdjustment = preference.tppaRefractionAdjustment + this.tppaRequest.stopTrackingWhenDone = preference.tppaStopTrackingWhenDone + this.tppaRequest.stepDistance = preference.tppaStepDistance + this.tppaRequest.plateSolver.type = preference.tppaPlateSolverType + this.darvRequest.initialPause = preference.darvInitialPause + this.darvRequest.exposureTime = preference.darvExposureTime + this.darvHemisphere = preference.darvHemisphere + + if (this.camera.name) { + Object.assign(this.tppaRequest.capture, this.preference.cameraStartCaptureForTPPA(this.camera).get(this.tppaRequest.capture)) + Object.assign(this.darvRequest.capture, this.preference.cameraStartCaptureForDARV(this.camera).get(this.darvRequest.capture)) } - this.cameraConnected = this.camera.connected + this.plateSolverChanged() } - private updateGuideOutput() { - if (!this.guideOutput) { - return + savePreference() { + if (this.tab === 0) { + this.preference.cameraStartCaptureForTPPA(this.camera).set(this.tppaRequest.capture) + } else if (this.tab === 1) { + this.preference.cameraStartCaptureForDARV(this.camera).set(this.darvRequest.capture) } - this.guideOutputConnected = this.guideOutput.connected - } - - private makeCameraStartCapture(camera: Camera): CameraStartCapture { - const preference = this.storage.get(cameraPreferenceKey(camera), {}) - - return { - exposureTime: 0, - exposureAmount: 0, - exposureDelay: 0, - frameType: 'LIGHT', - autoSave: false, - autoSubFolderMode: 'OFF', - x: preference.x ?? camera.minX, - y: preference.y ?? camera.minY, - width: preference.width ?? camera.maxWidth, - height: preference.height ?? camera.maxHeight, - binX: preference.binX ?? 1, - binY: preference.binY ?? 1, - gain: preference.gain ?? 0, - offset: preference.offset ?? 0, - frameFormat: preference.frameFormat ?? (camera.frameFormats[0] || ''), + const preference: AlignmentPreference = { + tppaStartFromCurrentPosition: this.tppaRequest.startFromCurrentPosition, + tppaEastDirection: this.tppaRequest.eastDirection, + tppaRefractionAdjustment: this.tppaRequest.refractionAdjustment, + tppaStopTrackingWhenDone: this.tppaRequest.stopTrackingWhenDone, + tppaStepDistance: this.tppaRequest.stepDistance, + tppaPlateSolverType: this.tppaRequest.plateSolver.type, + darvInitialPause: this.darvRequest.initialPause, + darvExposureTime: this.darvRequest.exposureTime, + darvHemisphere: this.darvHemisphere, } + + this.preference.alignmentPreference().set(preference) } } \ No newline at end of file diff --git a/desktop/src/app/atlas/atlas.component.html b/desktop/src/app/atlas/atlas.component.html index 06d5368d6..133b236e0 100644 --- a/desktop/src/app/atlas/atlas.component.html +++ b/desktop/src/app/atlas/atlas.component.html @@ -252,12 +252,12 @@
+ label="Sync" size="small" /> + label="Go To" size="small" /> - + label="Slew" size="small" /> +
@@ -347,7 +347,7 @@
- + @@ -361,8 +361,9 @@
- - + + diff --git a/desktop/src/app/calibration/calibration.component.html b/desktop/src/app/calibration/calibration.component.html index 18b290ced..7b4ebf8ee 100644 --- a/desktop/src/app/calibration/calibration.component.html +++ b/desktop/src/app/calibration/calibration.component.html @@ -36,8 +36,9 @@

Frames

- +
+ pTooltip="View image" tooltipPosition="bottom" size="small" /> + pTooltip="Replace" tooltipPosition="bottom" size="small" /> --> + pTooltip="Delete" tooltipPosition="bottom" size="small" />
diff --git a/desktop/src/app/camera/camera.component.html b/desktop/src/app/camera/camera.component.html index 2fcb30834..683571c9c 100644 --- a/desktop/src/app/camera/camera.component.html +++ b/desktop/src/app/camera/camera.component.html @@ -12,11 +12,11 @@
+ pTooltip="View image" tooltipPosition="bottom" size="small" /> + pTooltip="Calibration" tooltipPosition="bottom" size="small" /> + icon="mdi mdi-dots-vertical" (click)="cameraMenu.show()" size="small" />
Exposure Time - +
@@ -106,9 +106,9 @@
- +
@@ -154,7 +154,7 @@
+ severity="info" size="small" pTooltip="Full size" tooltipPosition="bottom" />
@@ -201,11 +201,11 @@
+ severity="success" size="small" /> + severity="danger" size="small" /> + severity="info" size="small" />
diff --git a/desktop/src/app/camera/camera.component.ts b/desktop/src/app/camera/camera.component.ts index e8588f587..79d11f289 100644 --- a/desktop/src/app/camera/camera.component.ts +++ b/desktop/src/app/camera/camera.component.ts @@ -5,8 +5,8 @@ import { CameraExposureComponent } from '../../shared/components/camera-exposure import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' import { ElectronService } from '../../shared/services/electron.service' -import { LocalStorageService } from '../../shared/services/local-storage.service' -import { Camera, CameraDialogInput, CameraDialogMode, CameraPreference, CameraStartCapture, EMPTY_CAMERA, EMPTY_CAMERA_START_CAPTURE, ExposureMode, ExposureTimeUnit, FrameType, cameraPreferenceKey } from '../../shared/types/camera.types' +import { PreferenceService } from '../../shared/services/preference.service' +import { Camera, CameraDialogInput, CameraDialogMode, CameraPreference, CameraStartCapture, EMPTY_CAMERA, EMPTY_CAMERA_START_CAPTURE, ExposureMode, ExposureTimeUnit, FrameType, updateCameraStartCaptureFromCamera } from '../../shared/types/camera.types' import { FilterWheel } from '../../shared/types/wheel.types' import { AppComponent } from '../app.component' @@ -40,11 +40,19 @@ export class CameraComponent implements AfterContentInit, OnDestroy { } get canExposureTime() { - return this.mode !== 'FLAT_WIZARD' + return this.mode !== 'FLAT_WIZARD' && this.mode !== 'DARV' + } + + get canExposureTimeUnit() { + return this.mode !== 'TPPA' && this.mode !== 'DARV' + } + + get canExposureAmount() { + return this.mode === 'CAPTURE' } get canFrameType() { - return this.mode !== 'FLAT_WIZARD' + return this.mode !== 'FLAT_WIZARD' && this.mode !== 'DARV' } get canStartOrAbort() { @@ -122,7 +130,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { private api: ApiService, private browserWindow: BrowserWindowService, private electron: ElectronService, - private storage: LocalStorageService, + private preference: PreferenceService, private route: ActivatedRoute, ngZone: NgZone, ) { @@ -140,7 +148,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { electron.on('CAMERA.DETACHED', event => { if (event.device.name === this.camera.name) { ngZone.run(() => { - Object.assign(this.camera, event.device) + Object.assign(this.camera, EMPTY_CAMERA) }) } }) @@ -178,8 +186,8 @@ export class CameraComponent implements AfterContentInit, OnDestroy { this.mode = data.mode Object.assign(this.request, data.request) await this.cameraChanged(data.camera) - this.normalizeExposureTimeAndUnit(this.request.exposureTime) this.loadDefaultsForMode(data.mode) + this.normalizeExposureTimeAndUnit(this.request.exposureTime) } } @@ -189,6 +197,10 @@ export class CameraComponent implements AfterContentInit, OnDestroy { } else if (this.mode === 'FLAT_WIZARD') { this.exposureMode = 'SINGLE' this.request.frameType = 'FLAT' + } else if (mode === 'TPPA') { + this.exposureMode = 'FIXED' + this.exposureTimeUnit = ExposureTimeUnit.SECOND + this.request.exposureAmount = 1 } } @@ -204,6 +216,9 @@ export class CameraComponent implements AfterContentInit, OnDestroy { if (this.app) { this.app.subTitle = camera?.name ?? '' } + if (this.mode !== 'CAPTURE') { + this.app.subTitle += ` · ${this.mode}` + } } connect() { @@ -296,7 +311,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { this.api.cameraAbortCapture(this.camera) } - private static exposureUnitFactor(unit: ExposureTimeUnit) { + static exposureUnitFactor(unit: ExposureTimeUnit) { switch (unit) { case ExposureTimeUnit.MINUTE: return 1 case ExposureTimeUnit.SECOND: return 60 @@ -305,9 +320,9 @@ export class CameraComponent implements AfterContentInit, OnDestroy { } } - private updateExposureUnit(unit: ExposureTimeUnit) { + private updateExposureUnit(unit: ExposureTimeUnit, from: ExposureTimeUnit = this.exposureTimeUnit) { if (this.camera.exposureMax) { - const a = CameraComponent.exposureUnitFactor(this.exposureTimeUnit) + const a = CameraComponent.exposureUnitFactor(from) const b = CameraComponent.exposureUnitFactor(unit) const exposureTime = Math.trunc(this.request.exposureTime * b / a) const exposureTimeMin = Math.trunc(this.camera.exposureMin * b / 60000000) @@ -320,34 +335,33 @@ export class CameraComponent implements AfterContentInit, OnDestroy { } private normalizeExposureTimeAndUnit(exposureTime: number) { - const factors = [ - { unit: ExposureTimeUnit.MINUTE, time: 60000000 }, - { unit: ExposureTimeUnit.SECOND, time: 1000000 }, - { unit: ExposureTimeUnit.MILLISECOND, time: 1000 }, - ] - - for (const { unit, time } of factors) { - if (exposureTime >= time) { - const k = exposureTime / time - - // exposureTime is multiple of time. - if (k === Math.floor(k)) { - this.updateExposureUnit(unit) - return + if (this.canExposureTimeUnit) { + const factors = [ + { unit: ExposureTimeUnit.MINUTE, time: 60000000 }, + { unit: ExposureTimeUnit.SECOND, time: 1000000 }, + { unit: ExposureTimeUnit.MILLISECOND, time: 1000 }, + ] + + for (const { unit, time } of factors) { + if (exposureTime >= time) { + const k = exposureTime / time + + // exposureTime is multiple of time. + if (k === Math.floor(k)) { + this.updateExposureUnit(unit, ExposureTimeUnit.MICROSECOND) + return + } } } + } else { + this.updateExposureUnit(this.exposureTimeUnit, ExposureTimeUnit.MICROSECOND) } } private update() { if (this.camera.name) { if (this.camera.connected) { - this.request.x = Math.max(this.camera.minX, Math.min(this.request.x, this.camera.maxX)) - this.request.y = Math.max(this.camera.minY, Math.min(this.request.y, this.camera.maxY)) - this.request.width = Math.max(this.camera.minWidth, Math.min(this.request.width < 8 ? this.camera.maxWidth : this.request.width, this.camera.maxWidth)) - this.request.height = Math.max(this.camera.minHeight, Math.min(this.request.height < 8 ? this.camera.maxHeight : this.request.width, this.camera.maxHeight)) - if (this.camera.frameFormats.length && (!this.request.frameFormat || !this.camera.frameFormats.includes(this.request.frameFormat))) this.request.frameFormat = this.camera.frameFormats[0] - + updateCameraStartCaptureFromCamera(this.request, this.camera) this.updateExposureUnit(this.exposureTimeUnit) } @@ -366,7 +380,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { private loadPreference() { if (this.mode === 'CAPTURE' && this.camera.name) { - const preference = this.storage.get(cameraPreferenceKey(this.camera), {}) + const preference = this.preference.cameraPreference(this.camera).get() this.request.autoSave = preference.autoSave ?? false this.savePath = preference.savePath ?? '' @@ -399,30 +413,14 @@ export class CameraComponent implements AfterContentInit, OnDestroy { savePreference() { if (this.mode === 'CAPTURE' && this.camera.connected) { const preference: CameraPreference = { - autoSave: this.request.autoSave, - savePath: this.savePath, - autoSubFolderMode: this.request.autoSubFolderMode, + ...this.request, setpointTemperature: this.setpointTemperature, - exposureTime: this.request.exposureTime, exposureTimeUnit: this.exposureTimeUnit, exposureMode: this.exposureMode, - exposureDelay: this.request.exposureDelay, - exposureAmount: this.request.exposureAmount, - x: this.request.x, - y: this.request.y, - width: this.request.width, - height: this.request.height, subFrame: this.subFrame, - binX: this.request.binX, - binY: this.request.binY, - frameType: this.request.frameType, - gain: this.request.gain, - offset: this.request.offset, - frameFormat: this.request.frameFormat, - dither: this.request.dither, } - this.storage.set(cameraPreferenceKey(this.camera), preference) + this.preference.cameraPreference(this.camera).set(preference) } } diff --git a/desktop/src/app/filterwheel/filterwheel.component.html b/desktop/src/app/filterwheel/filterwheel.component.html index 6ceefce1f..75e344348 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.html +++ b/desktop/src/app/filterwheel/filterwheel.component.html @@ -47,7 +47,7 @@
+ (onClick)="filter && moveTo(filter)" size="small" />
@@ -73,7 +73,7 @@
+ severity="info" size="small" />
\ No newline at end of file diff --git a/desktop/src/app/filterwheel/filterwheel.component.ts b/desktop/src/app/filterwheel/filterwheel.component.ts index e0a986388..67744a045 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.ts +++ b/desktop/src/app/filterwheel/filterwheel.component.ts @@ -73,7 +73,7 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { electron.on('WHEEL.DETACHED', event => { if (event.device.name === this.wheel.name) { ngZone.run(() => { - Object.assign(this.wheel, event.device) + Object.assign(this.wheel, EMPTY_WHEEL) }) } }) @@ -171,7 +171,7 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { filters = this.filters } - const preference = this.preference.wheelPreferenceGet(this.wheel) + const preference = this.preference.wheelPreference(this.wheel).get() for (let position = 1; position <= filters.length; position++) { const name = preference.names?.[position - 1] ?? `Filter #${position}` @@ -187,7 +187,7 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { private loadPreference() { if (this.mode === 'CAPTURE' && this.wheel.name) { - const preference = this.preference.wheelPreferenceGet(this.wheel) + const preference = this.preference.wheelPreference(this.wheel).get() const shutterPosition = preference.shutterPosition ?? 0 this.filters.forEach(e => e.dark = e.position === shutterPosition) } @@ -202,7 +202,7 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { names: this.filters.map(e => e.name) } - this.preference.wheelPreferenceSet(this.wheel, preference) + this.preference.wheelPreference(this.wheel).set(preference) this.api.wheelSync(this.wheel, preference.names!) } } diff --git a/desktop/src/app/flat-wizard/flat-wizard.component.html b/desktop/src/app/flat-wizard/flat-wizard.component.html index 027618aa6..e8fd42240 100644 --- a/desktop/src/app/flat-wizard/flat-wizard.component.html +++ b/desktop/src/app/flat-wizard/flat-wizard.component.html @@ -6,7 +6,7 @@ styleClass="p-inputtext-sm border-0" (ngModelChange)="cameraChanged()" emptyMessage="No camera found" /> - +
@@ -14,9 +14,9 @@ styleClass="p-inputtext-sm border-0" (ngModelChange)="wheelChanged()" emptyMessage="No filter wheel found" /> - -
@@ -26,7 +26,7 @@
- @@ -34,7 +34,7 @@
- @@ -42,7 +42,7 @@
- @@ -50,7 +50,7 @@
- @@ -58,20 +58,20 @@
- + [maxSelectedLabels]="wheel.count" scrollHeight="105px" />
- - + +
\ 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 e0fa6105d..2aef1d0fd 100644 --- a/desktop/src/app/flat-wizard/flat-wizard.component.ts +++ b/desktop/src/app/flat-wizard/flat-wizard.component.ts @@ -5,9 +5,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 { Camera, EMPTY_CAMERA_START_CAPTURE } from '../../shared/types/camera.types' +import { Camera, EMPTY_CAMERA, EMPTY_CAMERA_START_CAPTURE, updateCameraStartCaptureFromCamera } from '../../shared/types/camera.types' import { FlatWizardRequest } from '../../shared/types/flat-wizard.types' -import { FilterSlot, FilterWheel } from '../../shared/types/wheel.types' +import { EMPTY_WHEEL, FilterSlot, FilterWheel } from '../../shared/types/wheel.types' import { AppComponent } from '../app.component' import { CameraComponent } from '../camera/camera.component' @@ -19,10 +19,10 @@ import { CameraComponent } from '../camera/camera.component' export class FlatWizardComponent implements AfterViewInit, OnDestroy { cameras: Camera[] = [] - camera?: Camera + camera = Object.assign({}, EMPTY_CAMERA) wheels: FilterWheel[] = [] - wheel?: FilterWheel + wheel = Object.assign({}, EMPTY_WHEEL) running = false @@ -86,18 +86,18 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { }) electron.on('CAMERA.UPDATED', event => { - if (event.device.name === this.camera?.name) { + if (event.device.name === this.camera.name) { ngZone.run(() => { - Object.assign(this.camera!, event.device) + Object.assign(this.camera, event.device) this.cameraChanged() }) } }) electron.on('WHEEL.UPDATED', event => { - if (event.device.name === this.wheel?.name) { + if (event.device.name === this.wheel.name) { ngZone.run(() => { - Object.assign(this.wheel!, event.device) + Object.assign(this.wheel, event.device) this.wheelChanged() }) } @@ -113,8 +113,8 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { ngOnDestroy() { } async showCameraDialog() { - if (await CameraComponent.showAsDialog(this.browserWindow, 'FLAT_WIZARD', this.camera!, this.request.captureRequest)) { - this.preference.flatWizardCameraSet(this.camera!, this.request.captureRequest) + if (this.camera.name && await CameraComponent.showAsDialog(this.browserWindow, 'FLAT_WIZARD', this.camera, this.request.captureRequest)) { + this.preference.cameraStartCaptureForFlatWizard(this.camera).set(this.request.captureRequest) } } @@ -123,29 +123,19 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { } wheelConnect() { - if (this.wheel?.connected) { + if (this.wheel.connected) { this.api.wheelDisconnect(this.wheel) } else { - this.api.wheelConnect(this.wheel!) + this.api.wheelConnect(this.wheel) } } private updateEntryFromCamera(camera?: Camera) { if (camera) { - const request = this.preference.flatWizardCameraGet(camera, this.request.captureRequest) + const request = this.preference.cameraStartCaptureForFlatWizard(camera).get(this.request.captureRequest) if (camera.connected) { - if (camera.maxX > 1) request.x = Math.max(camera.minX, Math.min(request.x, camera.maxX)) - if (camera.maxY > 1) request.y = Math.max(camera.minY, Math.min(request.y, camera.maxY)) - - if (camera.maxWidth > 1 && (request.width <= 0 || request.width > camera.maxWidth)) request.width = camera.maxWidth - if (camera.maxHeight > 1 && (request.height <= 0 || request.height > camera.maxHeight)) request.height = camera.maxHeight - - if (camera.maxBinX > 1) request.binX = Math.max(1, Math.min(request.binX, camera.maxBinX)) - if (camera.maxBinY > 1) request.binY = Math.max(1, Math.min(request.binY, camera.maxBinY)) - if (camera.gainMax) request.gain = Math.max(camera.gainMin, Math.min(request.gain, camera.gainMax)) - if (camera.offsetMax) request.offset = Math.max(camera.offsetMin, Math.min(request.offset, camera.offsetMax)) - if (!request.frameFormat || !camera.frameFormats.includes(request.frameFormat)) request.frameFormat = camera.frameFormats[0] + updateCameraStartCaptureFromCamera(request, camera) } this.request.captureRequest = request @@ -166,7 +156,7 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { filters = this.filters } - const preference = this.preference.wheelPreferenceGet(this.wheel) + const preference = this.preference.wheelPreference(this.wheel).get() for (let position = 1; position <= filters.length; position++) { const name = preference.names?.[position - 1] ?? `Filter #${position}` @@ -182,13 +172,13 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { } async start() { - await this.browserWindow.openCameraImage(this.camera!, 'FLAT_WIZARD') + await this.browserWindow.openCameraImage(this.camera, 'FLAT_WIZARD') // TODO: Iniciar para cada filtro selecionado. Usar os eventos para percorrer (se houver filtro). // Se Falhar, interrompe todo o fluxo. - this.api.flatWizardStart(this.camera!, this.request) + this.api.flatWizardStart(this.camera, this.request) } stop() { - this.api.flatWizardStop(this.camera!) + this.api.flatWizardStop(this.camera) } } diff --git a/desktop/src/app/focuser/focuser.component.ts b/desktop/src/app/focuser/focuser.component.ts index 3f519b6f3..f0fb4e02c 100644 --- a/desktop/src/app/focuser/focuser.component.ts +++ b/desktop/src/app/focuser/focuser.component.ts @@ -53,7 +53,7 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { electron.on('FOCUSER.DETACHED', event => { if (event.device.name === this.focuser.name) { ngZone.run(() => { - Object.assign(this.focuser, event.device) + Object.assign(this.focuser, EMPTY_FOCUSER) }) } }) diff --git a/desktop/src/app/framing/framing.component.html b/desktop/src/app/framing/framing.component.html index 1274870e9..69f584a7b 100644 --- a/desktop/src/app/framing/framing.component.html +++ b/desktop/src/app/framing/framing.component.html @@ -59,7 +59,7 @@
- +
diff --git a/desktop/src/app/image/image.component.html b/desktop/src/app/image/image.component.html index 95e090be3..a2fa5cffe 100644 --- a/desktop/src/app/image/image.component.html +++ b/desktop/src/app/image/image.component.html @@ -68,7 +68,7 @@ - + @@ -129,10 +129,10 @@
- - - - + + + +
@@ -214,17 +214,17 @@
+ [text]="true" severity="info" size="small" /> + [text]="true" severity="success" size="small" /> + [text]="true" severity="success" size="small" />
- + @@ -261,9 +261,9 @@ - - - + + + @@ -301,7 +301,7 @@ - + diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 71a040eff..6b116ecd7 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -12,33 +12,16 @@ import { SEPARATOR_MENU_ITEM } from '../../shared/constants' import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' import { ElectronService } from '../../shared/services/electron.service' -import { LocalStorageService } from '../../shared/services/local-storage.service' +import { PreferenceService } from '../../shared/services/preference.service' import { PrimeService } from '../../shared/services/prime.service' import { CheckableMenuItem, ToggleableMenuItem } from '../../shared/types/app.types' import { Angle, AstronomicalObject, DeepSkyObject, EquatorialCoordinateJ2000, Star } from '../../shared/types/atlas.types' import { Camera } from '../../shared/types/camera.types' -import { DetectedStar, EMPTY_IMAGE_SOLVED, FITSHeaderItem, ImageAnnotation, ImageChannel, ImageInfo, ImageSource, ImageStatisticsBitOption, SCNRProtectionMethod, SCNR_PROTECTION_METHODS } from '../../shared/types/image.types' +import { DetectedStar, EMPTY_IMAGE_SOLVED, FITSHeaderItem, ImageAnnotation, ImageChannel, ImageData, ImageInfo, ImagePreference, ImageStatisticsBitOption, SCNRProtectionMethod, SCNR_PROTECTION_METHODS } from '../../shared/types/image.types' import { Mount } from '../../shared/types/mount.types' -import { PlateSolverType } from '../../shared/types/settings.types' +import { DEFAULT_SOLVER_TYPES, PlateSolverType } from '../../shared/types/settings.types' import { CoordinateInterpolator, InterpolatedCoordinate } from '../../shared/utils/coordinate-interpolation' import { AppComponent } from '../app.component' -import { SettingsComponent } from '../settings/settings.component' - -export function imagePreferenceKey(camera?: Camera) { - return camera ? `image.${camera.name}` : 'image' -} - -export interface ImagePreference { - solverRadius?: number - solverType?: PlateSolverType -} - -export interface ImageData { - camera?: Camera - path?: string - source?: ImageSource - title?: string -} @Component({ selector: 'app-image', @@ -94,7 +77,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { solverCenterDEC = '' solverRadius = 4 readonly solvedData = Object.assign({}, EMPTY_IMAGE_SOLVED) - readonly solverTypes: PlateSolverType[] = ['ASTAP', 'ASTROMETRY_NET_ONLINE'] + readonly solverTypes: PlateSolverType[] = Object.assign([], DEFAULT_SOLVER_TYPES) solverType = this.solverTypes[0] crossHair = false @@ -377,7 +360,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { private api: ApiService, private electron: ElectronService, private browserWindow: BrowserWindowService, - private storage: LocalStorageService, + private preference: PreferenceService, private prime: PrimeService, private ngZone: NgZone, ) { @@ -716,7 +699,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.solving = true try { - const options = SettingsComponent.getPlateSolverOptions(this.storage, this.solverType) + const options = this.preference.plateSolverOptions(this.solverType).get() Object.assign(this.solvedData, await this.api.solveImage(options, this.imageData.path!, this.solverBlind, @@ -781,7 +764,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } private loadPreference(camera?: Camera) { - const preference = this.storage.get(imagePreferenceKey(camera), {}) + const preference = this.preference.imagePreference(camera).get() this.solverRadius = preference.solverRadius ?? this.solverRadius this.solverType = preference.solverType ?? this.solverTypes[0] } @@ -792,7 +775,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { solverType: this.solverType } - this.storage.set(imagePreferenceKey(this.imageData.camera), preference) + this.preference.imagePreference(this.imageData.camera).set(preference) } private async executeMount(action: (mount: Mount) => void) { diff --git a/desktop/src/app/indi/indi.component.html b/desktop/src/app/indi/indi.component.html index 9a0c7baab..8bb0f62eb 100644 --- a/desktop/src/app/indi/indi.component.html +++ b/desktop/src/app/indi/indi.component.html @@ -10,7 +10,7 @@
- +
diff --git a/desktop/src/app/indi/property/indi-property.component.html b/desktop/src/app/indi/property/indi-property.component.html index 6016f8293..458bb5a0e 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">
+ (onClick)="sendSwitch(item)" icon="mdi mdi-check" size="small" />
+ (onClick)="sendSwitch(item)" icon="pi" [severity]="item.value ? 'success' : 'danger'" size="small">
@@ -39,7 +39,8 @@
- +
@@ -62,7 +63,7 @@
- +
diff --git a/desktop/src/app/mount/mount.component.html b/desktop/src/app/mount/mount.component.html index ab1f75f82..d0f9e7cd3 100644 --- a/desktop/src/app/mount/mount.component.html +++ b/desktop/src/app/mount/mount.component.html @@ -81,7 +81,7 @@
- +
@@ -158,7 +158,7 @@
+ (onClick)="abort()" size="small" />
\ No newline at end of file diff --git a/desktop/src/shared/pipes/enum.pipe.ts b/desktop/src/shared/pipes/enum.pipe.ts index 8343f8a30..68b2f8aaf 100644 --- a/desktop/src/shared/pipes/enum.pipe.ts +++ b/desktop/src/shared/pipes/enum.pipe.ts @@ -1,9 +1,17 @@ import { Pipe, PipeTransform } from '@angular/core' +import { DARVState, TPPAState } from '../types/alignment.types' +import { Constellation, SatelliteGroupType, SkyObjectType } from '../types/atlas.types' +import { CameraCaptureState } from '../types/camera.types' +import { GuideState } from '../types/guider.types' +import { SCNRProtectionMethod } from '../types/image.types' + +export type EnumPipeKey = SCNRProtectionMethod | Constellation | SkyObjectType | SatelliteGroupType | + DARVState | TPPAState | GuideState | CameraCaptureState | 'ALL' @Pipe({ name: 'enum' }) export class EnumPipe implements PipeTransform { - readonly enums: Record = { + readonly enums: Record = { // General. 'ALL': 'All', // SCNRProtectiveMethod. @@ -319,13 +327,23 @@ export class EnumPipe implements PipeTransform { 'FORWARD': 'Forward', 'BACKWARD': 'Backward', 'IDLE': 'Idle', + 'SLEWING': 'Slewing', + 'SOLVING': 'Solving', + 'SOLVED': 'Solved', + 'COMPUTED': 'Computed', + 'FAILED': 'Failed', + 'FINISHED': 'Finished', // Camera Exposure. 'SETTLING': 'Settling', 'WAITING': 'Waiting', 'EXPOSURING': 'Exposuring', + 'CAPTURE_STARTED': undefined, + 'EXPOSURE_STARTED': undefined, + 'EXPOSURE_FINISHED': undefined, + 'CAPTURE_FINISHED': undefined } - transform(value: string) { + transform(value: EnumPipeKey) { return this.enums[value] ?? value } } diff --git a/desktop/src/shared/services/api.service.ts b/desktop/src/shared/services/api.service.ts index 9f2c01efc..b395d6d95 100644 --- a/desktop/src/shared/services/api.service.ts +++ b/desktop/src/shared/services/api.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core' import moment from 'moment' -import { DARVStart } from '../types/alignment.types' +import { DARVStart, TPPAStart } from '../types/alignment.types' import { Angle, BodyPosition, ComputedLocation, Constellation, DeepSkyObject, Location, MinorPlanet, Satellite, SatelliteGroupType, SkyObjectType, Twilight } from '../types/atlas.types' import { CalibrationFrame, CalibrationFrameGroup } from '../types/calibration.types' import { Camera, CameraStartCapture } from '../types/camera.types' @@ -539,6 +539,16 @@ export class ApiService { return this.http.put(`polar-alignment/darv/${camera.name}/${guideOutput.name}/stop`) } + // TPPA + + tppaStart(camera: Camera, mount: Mount, data: TPPAStart) { + return this.http.put(`polar-alignment/tppa/${camera.name}/${mount.name}/start`, data) + } + + tppaStop(camera: Camera, mount: Mount) { + return this.http.put(`polar-alignment/tppa/${camera.name}/${mount.name}/stop`) + } + // SEQUENCER sequencerStart(camera: Camera, plan: SequencePlan) { diff --git a/desktop/src/shared/services/browser-window.service.ts b/desktop/src/shared/services/browser-window.service.ts index c656ff548..7c562b289 100644 --- a/desktop/src/shared/services/browser-window.service.ts +++ b/desktop/src/shared/services/browser-window.service.ts @@ -2,12 +2,11 @@ import { Injectable } from '@angular/core' import { v4 as uuidv4 } from 'uuid' import { SkyAtlasData } from '../../app/atlas/atlas.component' import { FramingData } from '../../app/framing/framing.component' -import { ImageData } from '../../app/image/image.component' import { OpenWindow, OpenWindowOptions, OpenWindowOptionsWithData } from '../types/app.types' import { Camera, CameraDialogInput, CameraStartCapture } from '../types/camera.types' import { Device } from '../types/device.types' import { Focuser } from '../types/focuser.types' -import { ImageSource } from '../types/image.types' +import { ImageData, ImageSource } from '../types/image.types' import { Mount } from '../types/mount.types' import { FilterWheel, WheelDialogInput } from '../types/wheel.types' import { ElectronService } from './electron.service' @@ -90,7 +89,7 @@ export class BrowserWindowService { } openAlignment(options: OpenWindowOptions = {}) { - Object.assign(options, { icon: 'star', width: 400, height: 280 }) + Object.assign(options, { icon: 'star', width: 450, height: 360 }) this.openWindow({ ...options, id: 'alignment', path: 'alignment', data: undefined }) } diff --git a/desktop/src/shared/services/electron.service.ts b/desktop/src/shared/services/electron.service.ts index e2d17eed2..d20a563a9 100644 --- a/desktop/src/shared/services/electron.service.ts +++ b/desktop/src/shared/services/electron.service.ts @@ -7,7 +7,7 @@ import { Injectable } from '@angular/core' import * as childProcess from 'child_process' import { ipcRenderer, webFrame } from 'electron' import * as fs from 'fs' -import { DARVElapsed } from '../types/alignment.types' +import { DARVElapsed, TPPAElapsed } from '../types/alignment.types' import { ApiEventType, DeviceMessageEvent } from '../types/api.types' import { CloseWindow, InternalEventType, JsonFile, OpenDirectory, OpenFile, SaveJson } from '../types/app.types' import { Location } from '../types/atlas.types' @@ -47,6 +47,7 @@ type EventMappedType = { 'GUIDER.STEPPED': GuiderMessageEvent 'GUIDER.MESSAGE_RECEIVED': GuiderMessageEvent 'DARV_ALIGNMENT.ELAPSED': DARVElapsed + 'TPPA_ALIGNMENT.ELAPSED': TPPAElapsed 'DATA.CHANGED': any 'LOCATION.CHANGED': Location 'SEQUENCER.ELAPSED': SequencerElapsed diff --git a/desktop/src/shared/services/preference.service.ts b/desktop/src/shared/services/preference.service.ts index 084a59f12..1e091b212 100644 --- a/desktop/src/shared/services/preference.service.ts +++ b/desktop/src/shared/services/preference.service.ts @@ -1,30 +1,67 @@ import { Injectable } from '@angular/core' -import { Camera, CameraStartCapture, EMPTY_CAMERA_START_CAPTURE } from '../types/camera.types' +import { AlignmentPreference, EMPTY_ALIGNMENT_PREFERENCE } from '../types/alignment.types' +import { Camera, CameraPreference, CameraStartCapture, EMPTY_CAMERA_PREFERENCE } from '../types/camera.types' +import { EMPTY_IMAGE_PREFERENCE, ImagePreference } from '../types/image.types' +import { EMPTY_PLATE_SOLVER_OPTIONS, PlateSolverOptions, PlateSolverType } from '../types/settings.types' import { FilterWheel, WheelPreference } from '../types/wheel.types' import { LocalStorageService } from './local-storage.service' +export class PreferenceData { + + constructor(private storage: LocalStorageService, private key: string, private defaultValue: T | (() => T)) { } + + has() { + return this.storage.has(this.key) + } + + get(defaultValue?: T | (() => T)): T { + return this.storage.get(this.key, defaultValue ?? this.defaultValue) + } + + set(value: T | undefined) { + this.storage.set(this.key, value) + } + + remove() { + this.storage.delete(this.key) + } +} + @Injectable({ providedIn: 'root' }) export class PreferenceService { constructor(private storage: LocalStorageService) { } - // WHEEL. + wheelPreference(wheel: FilterWheel) { + return new PreferenceData(this.storage, `wheel.${wheel.name}`, {}) + } + + cameraPreference(camera: Camera) { + return new PreferenceData(this.storage, `camera.${camera.name}`, () => Object.assign({}, EMPTY_CAMERA_PREFERENCE)) + } + + cameraStartCaptureForFlatWizard(camera: Camera) { + return new PreferenceData(this.storage, `camera.${camera.name}.flatWizard`, () => this.cameraPreference(camera).get()) + } - wheelPreferenceGet(wheel: FilterWheel) { - return this.storage.get(`wheel.${wheel.name}`, {}) + cameraStartCaptureForDARV(camera: Camera) { + return new PreferenceData(this.storage, `camera.${camera.name}.darv`, () => this.cameraPreference(camera).get()) } - wheelPreferenceSet(wheel: FilterWheel, preference: WheelPreference) { - this.storage.set(`wheel.${wheel.name}`, preference) + cameraStartCaptureForTPPA(camera: Camera) { + return new PreferenceData(this.storage, `camera.${camera.name}.tppa`, () => this.cameraPreference(camera).get()) } - // FLAT WIZARD. + plateSolverOptions(type: PlateSolverType) { + return new PreferenceData(this.storage, `settings.plateSolver.${type}`, () => { ...EMPTY_PLATE_SOLVER_OPTIONS, type }) + } - flatWizardCameraGet(camera: Camera, value: CameraStartCapture = EMPTY_CAMERA_START_CAPTURE) { - return this.storage.get(`flatWizard.camera.${camera.name}`, value) + imagePreference(camera?: Camera) { + const key = camera ? `image.${camera.name}` : 'image' + return new PreferenceData(this.storage, key, () => EMPTY_IMAGE_PREFERENCE) } - flatWizardCameraSet(camera: Camera, capture?: CameraStartCapture) { - this.storage.set(`flatWizard.camera.${camera.name}`, capture) + alignmentPreference() { + return new PreferenceData(this.storage, `alignment`, () => Object.assign({}, EMPTY_ALIGNMENT_PREFERENCE)) } } \ No newline at end of file diff --git a/desktop/src/shared/types/alignment.types.ts b/desktop/src/shared/types/alignment.types.ts index e62741ffd..a39f2749e 100644 --- a/desktop/src/shared/types/alignment.types.ts +++ b/desktop/src/shared/types/alignment.types.ts @@ -1,12 +1,42 @@ import { Camera, CameraStartCapture } from './camera.types' import { GuideDirection, GuideOutput } from './guider.types' +import { Mount } from './mount.types' +import { PlateSolverOptions, PlateSolverType } from './settings.types' export type Hemisphere = 'NORTHERN' | 'SOUTHERN' export type DARVState = 'IDLE' | 'INITIAL_PAUSE' | 'FORWARD' | 'BACKWARD' +export type TPPAState = 'IDLE' | 'SLEWING' | 'SOLVING' | 'SOLVED' | 'COMPUTED' | 'FAILED' | 'FINISHED' + +export type AlignmentMethod = 'DARV' | 'TPPA' + +export interface AlignmentPreference { + darvInitialPause: number + darvExposureTime: number + darvHemisphere: Hemisphere + tppaStartFromCurrentPosition: boolean + tppaEastDirection: boolean + tppaRefractionAdjustment: boolean + tppaStopTrackingWhenDone: boolean + tppaStepDistance: number + tppaPlateSolverType: PlateSolverType +} + +export const EMPTY_ALIGNMENT_PREFERENCE: AlignmentPreference = { + darvInitialPause: 5, + darvExposureTime: 30, + darvHemisphere: 'NORTHERN', + tppaStartFromCurrentPosition: true, + tppaEastDirection: true, + tppaRefractionAdjustment: true, + tppaStopTrackingWhenDone: true, + tppaStepDistance: 10, + tppaPlateSolverType: 'ASTAP', +} + export interface DARVStart { - capture?: CameraStartCapture + capture: CameraStartCapture exposureTime: number initialPause: number direction: GuideDirection @@ -21,3 +51,25 @@ export interface DARVElapsed extends MessageEvent { state: DARVState direction?: GuideDirection } + +export interface TPPAStart { + capture: CameraStartCapture + plateSolver: PlateSolverOptions + startFromCurrentPosition: boolean + eastDirection: boolean + refractionAdjustment: boolean + stopTrackingWhenDone: boolean + stepDistance: number +} + +export interface TPPAElapsed extends MessageEvent { + camera: Camera + mount?: Mount + elapsedTime: number + stepCount: number + state: TPPAState + rightAscension: number + declination: number + azimuth: number + altitude: number +} diff --git a/desktop/src/shared/types/camera.types.ts b/desktop/src/shared/types/camera.types.ts index 49e261286..86ceec0b4 100644 --- a/desktop/src/shared/types/camera.types.ts +++ b/desktop/src/shared/types/camera.types.ts @@ -3,7 +3,7 @@ import { Thermometer } from './auxiliary.types' import { PropertyState } from './device.types' import { GuideOutput } from './guider.types' -export type CameraDialogMode = 'CAPTURE' | 'SEQUENCER' | 'FLAT_WIZARD' +export type CameraDialogMode = 'CAPTURE' | 'SEQUENCER' | 'FLAT_WIZARD' | 'TPPA' | 'DARV' export type FrameType = 'LIGHT' | 'DARK' | 'FLAT' | 'BIAS' @@ -176,6 +176,22 @@ export const EMPTY_CAMERA_START_CAPTURE: CameraStartCapture = { } } +export function updateCameraStartCaptureFromCamera(request: CameraStartCapture, camera: Camera) { + if (camera.maxX > 1) request.x = Math.max(camera.minX, Math.min(request.x, camera.maxX)) + if (camera.maxY > 1) request.y = Math.max(camera.minY, Math.min(request.y, camera.maxY)) + + if (camera.maxWidth > 1 && (request.width <= 1 || request.width > camera.maxWidth)) request.width = camera.maxWidth + if (camera.maxHeight > 1 && (request.height <= 1 || request.height > camera.maxHeight)) request.height = camera.maxHeight + if (camera.minWidth > 1 && request.width < camera.minWidth) request.width = camera.minWidth + if (camera.minHeight > 1 && request.height < camera.minHeight) request.height = camera.minHeight + + if (camera.maxBinX > 1) request.binX = Math.max(1, Math.min(request.binX, camera.maxBinX)) + if (camera.maxBinY > 1) request.binY = Math.max(1, Math.min(request.binY, camera.maxBinY)) + if (camera.gainMax) request.gain = Math.max(camera.gainMin, Math.min(request.gain, camera.gainMax)) + if (camera.offsetMax) request.offset = Math.max(camera.offsetMin, Math.min(request.offset, camera.offsetMax)) + if (!request.frameFormat || !camera.frameFormats.includes(request.frameFormat)) request.frameFormat = camera.frameFormats[0] +} + export interface CameraCaptureElapsed extends MessageEvent { camera: Camera exposureAmount: number @@ -200,15 +216,19 @@ export interface CameraDialogInput { request: CameraStartCapture } -export function cameraPreferenceKey(camera: Camera) { - return `camera.${camera.name}` +export interface CameraPreference extends CameraStartCapture { + setpointTemperature: number + exposureTimeUnit: ExposureTimeUnit + exposureMode: ExposureMode + subFrame: boolean } -export interface CameraPreference extends Partial { - setpointTemperature?: number - exposureTimeUnit?: ExposureTimeUnit - exposureMode?: ExposureMode - subFrame?: boolean +export const EMPTY_CAMERA_PREFERENCE: CameraPreference = { + ...EMPTY_CAMERA_START_CAPTURE, + setpointTemperature: 0, + exposureTimeUnit: ExposureTimeUnit.MICROSECOND, + exposureMode: 'SINGLE', + subFrame: false, } export interface CameraExposureInfo { diff --git a/desktop/src/shared/types/image.types.ts b/desktop/src/shared/types/image.types.ts index 2001d029d..999069021 100644 --- a/desktop/src/shared/types/image.types.ts +++ b/desktop/src/shared/types/image.types.ts @@ -1,5 +1,6 @@ import { AstronomicalObject, DeepSkyObject, EquatorialCoordinateJ2000, Star } from './atlas.types' import { Camera } from './camera.types' +import { PlateSolverType } from './settings.types' export type ImageChannel = 'RED' | 'GREEN' | 'BLUE' | 'GRAY' | 'NONE' @@ -92,3 +93,20 @@ export interface ImageStatistics { minimum: number maximum: number } + +export interface ImagePreference { + solverRadius?: number + solverType?: PlateSolverType +} + +export const EMPTY_IMAGE_PREFERENCE: ImagePreference = { + solverRadius: 4, + solverType: 'ASTROMETRY_NET_ONLINE' +} + +export interface ImageData { + camera?: Camera + path?: string + source?: ImageSource + title?: string +} diff --git a/desktop/src/shared/types/settings.types.ts b/desktop/src/shared/types/settings.types.ts index 6d95db4be..df1391d7b 100644 --- a/desktop/src/shared/types/settings.types.ts +++ b/desktop/src/shared/types/settings.types.ts @@ -1,5 +1,7 @@ export type PlateSolverType = 'ASTROMETRY_NET' | 'ASTROMETRY_NET_ONLINE' | 'ASTAP' +export const DEFAULT_SOLVER_TYPES: PlateSolverType[] = ['ASTROMETRY_NET_ONLINE', 'ASTAP'] + export interface PlateSolverOptions { type: PlateSolverType executablePath: string @@ -15,5 +17,5 @@ export const EMPTY_PLATE_SOLVER_OPTIONS: PlateSolverOptions = { downsampleFactor: 0, apiUrl: 'https://nova.astrometry.net/', apiKey: '', - timeout: 0, + timeout: 600, } diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt index 2dc81380d..ee38537b2 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt @@ -1,5 +1,6 @@ package nebulosa.alignment.polar.point.three +import nebulosa.common.concurrency.cancel.CancellationToken import nebulosa.constants.DEG2RAD import nebulosa.fits.declination import nebulosa.fits.observationDate @@ -37,9 +38,10 @@ data class ThreePointPolarAlignment( rightAscension: Angle = image.header.rightAscension, declination: Angle = image.header.declination, radius: Angle = DEFAULT_RADIUS, + cancellationToken: CancellationToken = CancellationToken.NONE, ): ThreePointPolarAlignmentResult { val solution = try { - solver.solve(path, image, rightAscension, declination, radius) + solver.solve(path, image, rightAscension, declination, radius, cancellationToken = cancellationToken) } catch (e: PlateSolvingException) { return ThreePointPolarAlignmentResult.NoPlateSolution(e) } @@ -51,15 +53,13 @@ data class ThreePointPolarAlignment( positions[min(state, 2)] = solution.position(time) - if (state >= 2) { + if (state++ >= 2) { val polarErrorDetermination = PolarErrorDetermination(positions[0]!!, positions[1]!!, positions[2]!!, longitude, latitude) val (azimuth, altitude) = polarErrorDetermination.compute() - return ThreePointPolarAlignmentResult.Measured(azimuth, altitude) + return ThreePointPolarAlignmentResult.Measured(solution.rightAscension, solution.declination, azimuth, altitude) } - state++ - - return ThreePointPolarAlignmentResult.NeedMoreMeasurement + return ThreePointPolarAlignmentResult.NeedMoreMeasurement(solution.rightAscension, solution.declination) } } diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt index 9ad931a53..6496728d5 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt @@ -5,9 +5,12 @@ import nebulosa.plate.solving.PlateSolvingException sealed interface ThreePointPolarAlignmentResult { - data object NeedMoreMeasurement : ThreePointPolarAlignmentResult + data class NeedMoreMeasurement(@JvmField val rightAscension: Angle, @JvmField val declination: Angle) : ThreePointPolarAlignmentResult - data class Measured(@JvmField val azimuth: Angle, @JvmField val altitude: Angle) : ThreePointPolarAlignmentResult + data class Measured( + @JvmField val rightAscension: Angle, @JvmField val declination: Angle, + @JvmField val azimuth: Angle, @JvmField val altitude: Angle, + ) : ThreePointPolarAlignmentResult data class NoPlateSolution(@JvmField val exception: PlateSolvingException?) : ThreePointPolarAlignmentResult } diff --git a/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt b/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt index eb4bba758..20dd3cd3b 100644 --- a/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt +++ b/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt @@ -1,5 +1,6 @@ package nebulosa.astap.plate.solving +import nebulosa.common.concurrency.cancel.CancellationToken import nebulosa.common.process.ProcessExecutor import nebulosa.fits.Header import nebulosa.fits.NOAOExt @@ -33,6 +34,7 @@ class AstapPlateSolver(path: Path) : PlateSolver { path: Path?, image: Image?, centerRA: Angle, centerDEC: Angle, radius: Angle, downsampleFactor: Int, timeout: Duration?, + cancellationToken: CancellationToken, ): PlateSolution { requireNotNull(path) { "path is required" } @@ -59,7 +61,8 @@ class AstapPlateSolver(path: Path) : PlateSolver { LOG.info("local solving. command={}", arguments) try { - val process = executor.execute(arguments, timeout?.takeIf { it.toSeconds() > 0 } ?: Duration.ofMinutes(5), path.parent) + val timeoutOrDefault = timeout?.takeIf { it.toSeconds() > 0 } ?: Duration.ofMinutes(5) + val process = executor.execute(arguments, timeoutOrDefault, path.parent, cancellationToken) if (process.isAlive) process.destroyForcibly() LOG.info("astap exited. code={}", process.exitValue()) diff --git a/nebulosa-astrometrynet-jna/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LibAstrometryNetPlateSolver.kt b/nebulosa-astrometrynet-jna/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LibAstrometryNetPlateSolver.kt index 64b054987..b3bcf6ed8 100644 --- a/nebulosa-astrometrynet-jna/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LibAstrometryNetPlateSolver.kt +++ b/nebulosa-astrometrynet-jna/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LibAstrometryNetPlateSolver.kt @@ -1,5 +1,6 @@ package nebulosa.astrometrynet.plate.solving +import nebulosa.common.concurrency.cancel.CancellationToken import nebulosa.imaging.Image import nebulosa.math.Angle import nebulosa.plate.solving.PlateSolution @@ -13,6 +14,7 @@ data class LibAstrometryNetPlateSolver(private val solver: LibAstrometryNet) : P path: Path?, image: Image?, centerRA: Angle, centerDEC: Angle, radius: Angle, downsampleFactor: Int, timeout: Duration?, + cancellationToken: CancellationToken?, ): PlateSolution { return PlateSolution.NO_SOLUTION } diff --git a/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LocalAstrometryNetPlateSolver.kt b/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LocalAstrometryNetPlateSolver.kt index 3ff6a5fd9..455f1ca60 100644 --- a/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LocalAstrometryNetPlateSolver.kt +++ b/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LocalAstrometryNetPlateSolver.kt @@ -1,5 +1,6 @@ package nebulosa.astrometrynet.plate.solving +import nebulosa.common.concurrency.cancel.CancellationToken import nebulosa.common.process.ProcessExecutor import nebulosa.imaging.Image import nebulosa.log.loggerFor @@ -24,6 +25,7 @@ class LocalAstrometryNetPlateSolver(path: Path) : PlateSolver { path: Path?, image: Image?, centerRA: Angle, centerDEC: Angle, radius: Angle, downsampleFactor: Int, timeout: Duration?, + cancellationToken: CancellationToken, ): PlateSolution { requireNotNull(path) { "path is required" } @@ -52,7 +54,7 @@ class LocalAstrometryNetPlateSolver(path: Path) : PlateSolver { arguments["$path"] = null - val process = executor.execute(arguments, Duration.ZERO, path.parent) + val process = executor.execute(arguments, Duration.ZERO, path.parent, cancellationToken) val buffer = process.inputReader() diff --git a/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/NovaAstrometryNetPlateSolver.kt b/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/NovaAstrometryNetPlateSolver.kt index 13daa78f6..a0f6a6bbe 100644 --- a/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/NovaAstrometryNetPlateSolver.kt +++ b/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/plate/solving/NovaAstrometryNetPlateSolver.kt @@ -3,6 +3,7 @@ package nebulosa.astrometrynet.plate.solving import nebulosa.astrometrynet.nova.NovaAstrometryNetService import nebulosa.astrometrynet.nova.Session import nebulosa.astrometrynet.nova.Upload +import nebulosa.common.concurrency.cancel.CancellationToken import nebulosa.fits.Header import nebulosa.imaging.Image import nebulosa.log.loggerFor @@ -43,6 +44,7 @@ data class NovaAstrometryNetPlateSolver( path: Path?, image: Image?, centerRA: Angle, centerDEC: Angle, radius: Angle, downsampleFactor: Int, timeout: Duration?, + cancellationToken: CancellationToken, ): PlateSolution { renewSession() @@ -69,13 +71,13 @@ data class NovaAstrometryNetPlateSolver( var timeLeft = timeout?.takeIf { it.toSeconds() > 0 }?.toMillis() ?: 300000L - while (timeLeft >= 0L) { + while (timeLeft >= 0L && !cancellationToken.isCancelled) { val startTime = System.currentTimeMillis() val status = service.submissionStatus(submission.subId).execute().body() ?: throw PlateSolvingException("failed to retrieve submission status") - if (status.solved) { + if (status.solved && !cancellationToken.isCancelled) { LOG.info("retrieving WCS from job. id={}", status.jobs[0]) val body = service.wcs(status.jobs[0]).execute().body() @@ -89,9 +91,11 @@ data class NovaAstrometryNetPlateSolver( return calibration ?: PlateSolution.NO_SOLUTION } - timeLeft -= System.currentTimeMillis() - startTime + 5000L + if (!cancellationToken.isCancelled) { + timeLeft -= System.currentTimeMillis() - startTime + 5000L - Thread.sleep(5000L) + Thread.sleep(5000L) + } } throw PlateSolvingException("the plate solving took a long time and finished") diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/process/ProcessExecutor.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/process/ProcessExecutor.kt index ba2f86f5b..ec0e8d71c 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/process/ProcessExecutor.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/process/ProcessExecutor.kt @@ -1,5 +1,6 @@ package nebulosa.common.process +import nebulosa.common.concurrency.cancel.CancellationToken import nebulosa.log.loggerFor import java.nio.file.Path import java.time.Duration @@ -11,6 +12,7 @@ open class ProcessExecutor(private val path: Path) { arguments: Map, timeout: Duration? = null, workingDir: Path? = null, + cancellationToken: CancellationToken = CancellationToken.NONE, ): Process { val args = ArrayList(arguments.size * 2) @@ -28,7 +30,8 @@ open class ProcessExecutor(private val path: Path) { LOG.info("executing process. pid={}, args={}", process.pid(), args) // TODO: READ OUTPUT STREAM LINE TO CALLBACK - // TODO: CANCELLATION + + cancellationToken.listen { process.destroyForcibly() } if (timeout == null || timeout.isNegative) process.waitFor() else process.waitFor(timeout.seconds, TimeUnit.SECONDS) diff --git a/nebulosa-plate-solving/build.gradle.kts b/nebulosa-plate-solving/build.gradle.kts index 861728854..12b716a2d 100644 --- a/nebulosa-plate-solving/build.gradle.kts +++ b/nebulosa-plate-solving/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { api(project(":nebulosa-math")) + api(project(":nebulosa-common")) api(project(":nebulosa-wcs")) api(project(":nebulosa-imaging")) implementation(project(":nebulosa-log")) diff --git a/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolver.kt b/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolver.kt index 783f8f255..13efcc880 100644 --- a/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolver.kt +++ b/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolver.kt @@ -1,5 +1,6 @@ package nebulosa.plate.solving +import nebulosa.common.concurrency.cancel.CancellationToken import nebulosa.imaging.Image import nebulosa.math.Angle import java.nio.file.Path @@ -11,5 +12,6 @@ interface PlateSolver { path: Path?, image: Image?, centerRA: Angle = 0.0, centerDEC: Angle = 0.0, radius: Angle = 0.0, downsampleFactor: Int = 0, timeout: Duration? = null, + cancellationToken: CancellationToken = CancellationToken.NONE, ): PlateSolution } diff --git a/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt b/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt index af8d2cdd1..a0f258586 100644 --- a/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt +++ b/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt @@ -1,5 +1,6 @@ package nebulosa.watney.plate.solving +import nebulosa.common.concurrency.cancel.CancellationToken import nebulosa.erfa.SphericalCoordinate import nebulosa.fits.Fits import nebulosa.fits.Header @@ -44,6 +45,7 @@ data class WatneyPlateSolver( path: Path?, image: Image?, centerRA: Angle, centerDEC: Angle, radius: Angle, downsampleFactor: Int, timeout: Duration?, + cancellationToken: CancellationToken, ): PlateSolution { val image = image ?: Fits(path!!).also(Fits::read).use(Image::open) val stars = (starDetector ?: DEFAULT_STAR_DETECTOR).detect(image) From fe9feaad7d0543253ad5a48d3745965fcf543183 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Mon, 12 Feb 2024 11:38:53 -0300 Subject: [PATCH 10/87] [api][desktop]: Implement Three Point Polar Alignment --- api/src/main/kotlin/nebulosa/api/README.md | 23 ++++++++++- .../api/alignment/polar/darv/DARVEvent.kt | 2 +- .../api/alignment/polar/tppa/TPPAEvent.kt | 34 +++++++++++----- .../api/alignment/polar/tppa/TPPAJob.kt | 14 ++++++- .../alignment/polar/tppa/TPPAStartRequest.kt | 2 +- .../api/alignment/polar/tppa/TPPAStep.kt | 15 +++++-- .../nebulosa/api/mounts/MountSlewStep.kt | 22 ++++++++--- .../app/alignment/alignment.component.html | 27 +++++++++++-- .../src/app/alignment/alignment.component.ts | 39 ++++++++++++++----- .../src/shared/services/electron.service.ts | 4 +- desktop/src/shared/types/alignment.types.ts | 18 +++++---- .../alignment/polar/point/three/Position.kt | 4 +- .../point/three/ThreePointPolarAlignment.kt | 9 +++-- .../astap/plate/solving/AstapPlateSolver.kt | 5 ++- 14 files changed, 169 insertions(+), 49 deletions(-) diff --git a/api/src/main/kotlin/nebulosa/api/README.md b/api/src/main/kotlin/nebulosa/api/README.md index 83ffe4b43..696cfae80 100644 --- a/api/src/main/kotlin/nebulosa/api/README.md +++ b/api/src/main/kotlin/nebulosa/api/README.md @@ -181,7 +181,7 @@ URL: `localhost:{PORT}/ws` ### DARV Polar Alignment -#### DARV_ALIGNMENT.ELAPSED +#### DARV.ELAPSED ```json5 { @@ -194,6 +194,27 @@ URL: `localhost:{PORT}/ws` } ``` +### Three Point Polar Alignment + +#### TPPA.ELAPSED + +```json5 +{ + "camera": {}, + "mount": {}, + "elapsedTime": 0, + "stepCount": 0, + "state": "SLEWING|SOLVING|SOLVED|COMPUTED|FAILED|FINISHED", + "rightAscension": "00h00m00s", + "declination": "00d00m00s", + "azimuthError": "00d00m00s", + "altitudeError": "00d00m00s", + "totalError": "00d00m00s", + "azimuthErrorDirection": "", + "altitudeErrorDirection": "" +} +``` + ### Flat Wizard #### FLAT_WIZARD.ELAPSED diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVEvent.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVEvent.kt index 588ae7f14..25f6d1696 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVEvent.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVEvent.kt @@ -21,5 +21,5 @@ sealed interface DARVEvent : MessageEvent { val state: DARVState override val eventName - get() = "DARV_ALIGNMENT.ELAPSED" + get() = "DARV.ELAPSED" } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt index 7d3dd31bf..d193b6b59 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt @@ -1,10 +1,14 @@ package nebulosa.api.alignment.polar.tppa +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import nebulosa.api.beans.converters.angle.DeclinationSerializer +import nebulosa.api.beans.converters.angle.RightAscensionSerializer import nebulosa.api.messages.MessageEvent import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.mount.Mount import nebulosa.math.Angle import java.time.Duration +import kotlin.math.hypot sealed interface TPPAEvent : MessageEvent { @@ -24,22 +28,31 @@ sealed interface TPPAEvent : MessageEvent { val declination: Angle get() = 0.0 - val azimuth: Angle + val azimuthError: Angle get() = 0.0 - val altitude: Angle + val altitudeError: Angle get() = 0.0 + val totalError: Angle + get() = 0.0 + + val azimuthErrorDirection: String + get() = "" + + val altitudeErrorDirection: String + get() = "" + override val eventName - get() = "TPPA_ALIGNMENT.ELAPSED" + get() = "TPPA.ELAPSED" data class Slewing( override val camera: Camera, override val mount: Mount?, override val stepCount: Int, override val elapsedTime: Duration, - override val rightAscension: Angle, - override val declination: Angle, + @field:JsonSerialize(using = RightAscensionSerializer::class) override val rightAscension: Angle, + @field:JsonSerialize(using = DeclinationSerializer::class) override val declination: Angle, ) : TPPAEvent { override val state = TPPAState.SLEWING @@ -60,8 +73,8 @@ sealed interface TPPAEvent : MessageEvent { override val mount: Mount?, override val stepCount: Int, override val elapsedTime: Duration, - override val rightAscension: Angle, - override val declination: Angle, + @field:JsonSerialize(using = RightAscensionSerializer::class) override val rightAscension: Angle, + @field:JsonSerialize(using = DeclinationSerializer::class) override val declination: Angle, ) : TPPAEvent { override val state = TPPAState.SOLVED @@ -72,10 +85,13 @@ sealed interface TPPAEvent : MessageEvent { override val mount: Mount?, override val stepCount: Int, override val elapsedTime: Duration, - override val azimuth: Double, - override val altitude: Double, + @field:JsonSerialize(using = DeclinationSerializer::class) override val azimuthError: Angle, + @field:JsonSerialize(using = DeclinationSerializer::class) override val altitudeError: Angle, + override val azimuthErrorDirection: String, + override val altitudeErrorDirection: String, ) : TPPAEvent { + @JsonSerialize(using = DeclinationSerializer::class) override val totalError = hypot(azimuthError, altitudeError) override val state = TPPAState.COMPUTED } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt index 58e4ca851..eadebae4f 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt @@ -55,7 +55,19 @@ data class TPPAJob( } override fun polarAlignmentComputed(step: TPPAStep, azimuth: Angle, altitude: Angle) { - onNext(TPPAEvent.Computed(step.camera, step.mount, step.stepCount, step.elapsedTime, azimuth, altitude)) + val azimuthErrorDir = when { + azimuth > 0 -> if (latitude > 0) "🠔 Move LEFT/WEST" else "🠔 Move LEFT/EAST" + azimuth < 0 -> if (latitude > 0) "Move RIGHT/EAST 🠖" else "Move RIGHT/WEST 🠖" + else -> "" + } + + val altitudeErrorDir = when { + altitude > 0 -> if (latitude > 0) "🠗 Move DOWN" else "Move UP 🠕" + altitude < 0 -> if (latitude > 0) "Move UP 🠕" else "🠗 Move DOWN" + else -> "" + } + + onNext(TPPAEvent.Computed(step.camera, step.mount, step.stepCount, step.elapsedTime, azimuth, altitude, azimuthErrorDir, altitudeErrorDir)) } override fun solverFailed(step: TPPAStep) { 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 8989610db..26b4d0efd 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 @@ -11,7 +11,7 @@ data class TPPAStartRequest( @field:NotNull @Valid val plateSolver: PlateSolverOptions = PlateSolverOptions.EMPTY, val startFromCurrentPosition: Boolean = true, val eastDirection: Boolean = true, - val refractionAdjustment: Boolean = false, + val compensateRefraction: Boolean = false, val stopTrackingWhenDone: Boolean = true, val stepDistance: Double = 10.0, // degrees ) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt index eb1ead59c..dfae22905 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt @@ -89,8 +89,10 @@ data class TPPAStep( stopwatch.start() if (mount != null) { + val stepDistance = if (request.eastDirection) request.stepDistance else -request.stepDistance + if (alignment.state in 1..2) { - val step = MountSlewStep(mount, mount.rightAscension + request.stepDistance.deg, mount.declination) + val step = MountSlewStep(mount, mount.rightAscension + stepDistance.deg, mount.declination) mountSlewStep = step listeners.forEach { it.slewStarted(this, step.rightAscension, step.declination) } step.executeSingle(stepExecution) @@ -106,10 +108,17 @@ data class TPPAStep( if (!cancellationToken.isCancelled) { val savedPath = cameraExposureStep.savedPath ?: return StepResult.FINISHED image = Fits(savedPath).also(Fits::read).use { image?.load(it, false) ?: Image.open(it, false) } + val radius = if (mount == null) 0.0 else ThreePointPolarAlignment.DEFAULT_RADIUS - val result = alignment.align(savedPath, image!!, mount?.rightAscension ?: 0.0, mount?.declination ?: 0.0, radius) - LOG.info("alignment completed. result=$result") + val result = alignment.align( + savedPath, image!!, mount?.rightAscension ?: 0.0, mount?.declination ?: 0.0, radius, + request.compensateRefraction, cancellationToken + ) + + LOG.info("alignment completed. result=$result, cancelled={}", cancellationToken.isCancelled) + + if (cancellationToken.isCancelled) return StepResult.FINISHED when (result) { is ThreePointPolarAlignmentResult.NeedMoreMeasurement -> { diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt index 720a37be7..69991f3be 100644 --- a/api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt @@ -3,6 +3,7 @@ package nebulosa.api.mounts import nebulosa.batch.processing.Step import nebulosa.batch.processing.StepExecution import nebulosa.batch.processing.StepResult +import nebulosa.batch.processing.delay.DelayStep import nebulosa.common.concurrency.latch.CountUpDownLatch import nebulosa.indi.device.mount.Mount import nebulosa.indi.device.mount.MountEvent @@ -15,6 +16,7 @@ import nebulosa.math.formatSignedDMS import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import java.time.Duration data class MountSlewStep( val mount: Mount, @@ -23,9 +25,10 @@ data class MountSlewStep( ) : Step { private val latch = CountUpDownLatch() + private val settleDelayStep = DelayStep(SETTLE_DURATION) - private val initialRA = mount.rightAscension - private val initialDEC = mount.declination + @Volatile private var initialRA = mount.rightAscension + @Volatile private var initialDEC = mount.declination @Subscribe(threadMode = ThreadMode.ASYNC) fun onMountEvent(event: MountEvent) { @@ -42,7 +45,7 @@ data class MountSlewStep( } override fun execute(stepExecution: StepExecution): StepResult { - if (mount.connected && + if (mount.connected && !mount.parked && !mount.parking && !mount.slewing && rightAscension.isFinite() && declination.isFinite() && (mount.rightAscension != rightAscension || mount.declination != declination) ) { @@ -50,7 +53,10 @@ data class MountSlewStep( latch.countUp() - LOG.info("moving mount. mount={}, ra={}, dec={}", mount, mount.rightAscension.formatHMS(), mount.declination.formatSignedDMS()) + LOG.info("moving mount. mount={}, ra={}, dec={}", mount, rightAscension.formatHMS(), declination.formatSignedDMS()) + + initialRA = mount.rightAscension + initialDEC = mount.declination if (j2000) { if (goTo) mount.goToJ2000(rightAscension, declination) @@ -62,9 +68,13 @@ data class MountSlewStep( latch.await() - LOG.info("mount moved. mount={}, ra={}, dec={}", mount, mount.rightAscension.formatHMS(), mount.declination.formatSignedDMS()) + LOG.info("mount moved. mount={}", mount) + + settleDelayStep.execute(stepExecution) EventBus.getDefault().unregister(this) + } else { + LOG.warn("cannot move mount. mount={}", mount) } return StepResult.FINISHED @@ -73,10 +83,12 @@ data class MountSlewStep( override fun stop(mayInterruptIfRunning: Boolean) { mount.abortMotion() latch.reset() + settleDelayStep.stop(mayInterruptIfRunning) } companion object { @JvmStatic private val LOG = loggerFor() + @JvmStatic private val SETTLE_DURATION: Duration = Duration.ofSeconds(5) } } diff --git a/desktop/src/app/alignment/alignment.component.html b/desktop/src/app/alignment/alignment.component.html index 674bac16c..c2baffd94 100644 --- a/desktop/src/app/alignment/alignment.component.html +++ b/desktop/src/app/alignment/alignment.component.html @@ -39,10 +39,15 @@ {{ darvDirection }} - {{ darvRemainingTime | exposureTime }} + {{ remainingTime | exposureTime }} - {{ darvProgress | percent:'1.1-1' }} + {{ progress | percent:'1.1-1' }} + + + + + {{ elapsedTime | exposureTime }} @@ -75,10 +80,26 @@ (ngModelChange)="savePreference()" />
-
+
+
+ Azimuth + {{ tppaAzimuthError }} + {{ tppaAzimuthErrorDirection }} +
+
+ Altitude + {{ tppaAltitudeError }} + {{ tppaAltitudeErrorDirection }} +
+
+ Total + {{ tppaTotalError }} +
+
{ + electron.on('TPPA.ELAPSED', event => { if (event.camera.name === this.camera.name && (!event.mount || event.mount.name === this.mount.name)) { ngZone.run(() => { this.status = event.state this.running = event.state !== 'FINISHED' + this.elapsedTime = event.elapsedTime + + if (event.state === 'COMPUTED') { + this.tppaAzimuthError = event.azimuthError + this.tppaAltitudeError = event.altitudeError + this.tppaAzimuthErrorDirection = event.azimuthErrorDirection + this.tppaAltitudeErrorDirection = event.altitudeErrorDirection + this.tppaTotalError = event.totalError + } else if (event.state === 'SOLVED' || event.state === 'SLEWING') { + this.tppaRightAscension = event.rightAscension + this.tppaDeclination = event.declination + } if (!this.running) { this.alignmentMethod = undefined @@ -168,13 +189,13 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } }) - electron.on('DARV_ALIGNMENT.ELAPSED', event => { + electron.on('DARV.ELAPSED', event => { if (event.camera.name === this.camera.name && event.guideOutput.name === this.guideOutput.name) { ngZone.run(() => { this.status = event.state - this.darvRemainingTime = event.remainingTime - this.darvProgress = event.progress + this.remainingTime = event.remainingTime + this.progress = event.progress this.running = event.remainingTime > 0 if (event.state === 'FORWARD' || event.state === 'BACKWARD') { @@ -306,7 +327,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { this.tppaRequest.startFromCurrentPosition = preference.tppaStartFromCurrentPosition this.tppaRequest.eastDirection = preference.tppaEastDirection - this.tppaRequest.refractionAdjustment = preference.tppaRefractionAdjustment + this.tppaRequest.compensateRefraction = preference.tppaCompensateRefraction this.tppaRequest.stopTrackingWhenDone = preference.tppaStopTrackingWhenDone this.tppaRequest.stepDistance = preference.tppaStepDistance this.tppaRequest.plateSolver.type = preference.tppaPlateSolverType @@ -332,7 +353,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { const preference: AlignmentPreference = { tppaStartFromCurrentPosition: this.tppaRequest.startFromCurrentPosition, tppaEastDirection: this.tppaRequest.eastDirection, - tppaRefractionAdjustment: this.tppaRequest.refractionAdjustment, + tppaCompensateRefraction: this.tppaRequest.compensateRefraction, tppaStopTrackingWhenDone: this.tppaRequest.stopTrackingWhenDone, tppaStepDistance: this.tppaRequest.stepDistance, tppaPlateSolverType: this.tppaRequest.plateSolver.type, diff --git a/desktop/src/shared/services/electron.service.ts b/desktop/src/shared/services/electron.service.ts index d20a563a9..186c73793 100644 --- a/desktop/src/shared/services/electron.service.ts +++ b/desktop/src/shared/services/electron.service.ts @@ -46,8 +46,8 @@ type EventMappedType = { 'GUIDER.UPDATED': GuiderMessageEvent 'GUIDER.STEPPED': GuiderMessageEvent 'GUIDER.MESSAGE_RECEIVED': GuiderMessageEvent - 'DARV_ALIGNMENT.ELAPSED': DARVElapsed - 'TPPA_ALIGNMENT.ELAPSED': TPPAElapsed + 'DARV.ELAPSED': DARVElapsed + 'TPPA.ELAPSED': TPPAElapsed 'DATA.CHANGED': any 'LOCATION.CHANGED': Location 'SEQUENCER.ELAPSED': SequencerElapsed diff --git a/desktop/src/shared/types/alignment.types.ts b/desktop/src/shared/types/alignment.types.ts index a39f2749e..9f109932e 100644 --- a/desktop/src/shared/types/alignment.types.ts +++ b/desktop/src/shared/types/alignment.types.ts @@ -1,3 +1,4 @@ +import { Angle } from './atlas.types' import { Camera, CameraStartCapture } from './camera.types' import { GuideDirection, GuideOutput } from './guider.types' import { Mount } from './mount.types' @@ -17,7 +18,7 @@ export interface AlignmentPreference { darvHemisphere: Hemisphere tppaStartFromCurrentPosition: boolean tppaEastDirection: boolean - tppaRefractionAdjustment: boolean + tppaCompensateRefraction: boolean tppaStopTrackingWhenDone: boolean tppaStepDistance: number tppaPlateSolverType: PlateSolverType @@ -29,7 +30,7 @@ export const EMPTY_ALIGNMENT_PREFERENCE: AlignmentPreference = { darvHemisphere: 'NORTHERN', tppaStartFromCurrentPosition: true, tppaEastDirection: true, - tppaRefractionAdjustment: true, + tppaCompensateRefraction: true, tppaStopTrackingWhenDone: true, tppaStepDistance: 10, tppaPlateSolverType: 'ASTAP', @@ -57,7 +58,7 @@ export interface TPPAStart { plateSolver: PlateSolverOptions startFromCurrentPosition: boolean eastDirection: boolean - refractionAdjustment: boolean + compensateRefraction: boolean stopTrackingWhenDone: boolean stepDistance: number } @@ -68,8 +69,11 @@ export interface TPPAElapsed extends MessageEvent { elapsedTime: number stepCount: number state: TPPAState - rightAscension: number - declination: number - azimuth: number - altitude: number + rightAscension: Angle + declination: Angle + azimuthError: Angle + altitudeError: Angle + totalError: Angle + azimuthErrorDirection: string + altitudeErrorDirection: string } diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt index ead777294..fb28668e4 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt @@ -22,12 +22,12 @@ internal data class Position( rightAscension: Angle, declination: Angle, longitude: Angle, latitude: Angle, time: InstantOfTime = CurrentTime, - refract: Boolean = false, + compensateRefraction: Boolean = false, ): Position { // SOFA.CelestialToTopocentric. val dut1 = IERS.delta(time) val (xp, yp) = IERS.pmAngles(time) - val pressure = if (refract) ONE_ATM else 0.0 + val pressure = if (compensateRefraction) ONE_ATM else 0.0 // @formatter:off val (b) = eraAtco13(rightAscension, declination, 0.0, 0.0, 0.0, 0.0, time.utc.whole, time.utc.fraction, dut1, longitude, latitude, 0.0, xp, yp, pressure, 15.0, 0.5, 0.55) // @formatter:on diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt index ee38537b2..5b7772fc8 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt @@ -38,6 +38,7 @@ data class ThreePointPolarAlignment( rightAscension: Angle = image.header.rightAscension, declination: Angle = image.header.declination, radius: Angle = DEFAULT_RADIUS, + compensateRefraction: Boolean = false, cancellationToken: CancellationToken = CancellationToken.NONE, ): ThreePointPolarAlignmentResult { val solution = try { @@ -46,12 +47,12 @@ data class ThreePointPolarAlignment( return ThreePointPolarAlignmentResult.NoPlateSolution(e) } - if (!solution.solved) { + if (!solution.solved || cancellationToken.isCancelled) { return ThreePointPolarAlignmentResult.NoPlateSolution(null) } else { val time = image.header.observationDate?.let { UTC(TimeYMDHMS(it)) } ?: UTC.now() - positions[min(state, 2)] = solution.position(time) + positions[min(state, 2)] = solution.position(time, compensateRefraction) if (state++ >= 2) { val polarErrorDetermination = PolarErrorDetermination(positions[0]!!, positions[1]!!, positions[2]!!, longitude, latitude) @@ -68,8 +69,8 @@ data class ThreePointPolarAlignment( positions.fill(null) } - private fun PlateSolution.position(time: UTC): Position { - return Position(rightAscension, declination, longitude, latitude, time) + private fun PlateSolution.position(time: UTC, compensateRefraction: Boolean): Position { + return Position(rightAscension, declination, longitude, latitude, time, compensateRefraction) } companion object { diff --git a/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt b/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt index 20dd3cd3b..541ce21ec 100644 --- a/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt +++ b/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt @@ -58,14 +58,17 @@ class AstapPlateSolver(path: Path) : PlateSolver { arguments["-f"] = path - LOG.info("local solving. command={}", arguments) + LOG.info("ASTAP solving. command={}", arguments) try { val timeoutOrDefault = timeout?.takeIf { it.toSeconds() > 0 } ?: Duration.ofMinutes(5) val process = executor.execute(arguments, timeoutOrDefault, path.parent, cancellationToken) + if (process.isAlive) process.destroyForcibly() LOG.info("astap exited. code={}", process.exitValue()) + if (cancellationToken.isCancelled) return PlateSolution.NO_SOLUTION + val ini = Properties() Paths.get("$basePath", "$baseName.ini").inputStream().use(ini::load) From d5f7c73d21459e94130704c6bb224c6dd7026975 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Mon, 12 Feb 2024 14:47:29 -0300 Subject: [PATCH 11/87] [api][desktop]: Implement Three Point Polar Alignment --- api/src/main/kotlin/nebulosa/api/README.md | 3 +- .../polar/PolarAlignmentController.kt | 27 ++++++---- .../alignment/polar/PolarAlignmentService.kt | 16 ++++-- .../api/alignment/polar/darv/DARVEvent.kt | 44 ++++++++++++++-- .../api/alignment/polar/darv/DARVExecutor.kt | 13 ++--- .../api/alignment/polar/darv/DARVFinished.kt | 16 ------ .../polar/darv/DARVGuidePulseElapsed.kt | 16 ------ .../polar/darv/DARVInitialPauseElapsed.kt | 16 ------ .../api/alignment/polar/darv/DARVJob.kt | 8 +-- .../api/alignment/polar/darv/DARVStarted.kt | 17 ------- .../api/alignment/polar/tppa/TPPAEvent.kt | 33 ++++++------ .../api/alignment/polar/tppa/TPPAExecutor.kt | 13 ++--- .../api/alignment/polar/tppa/TPPAJob.kt | 20 +++++--- .../api/alignment/polar/tppa/TPPAListener.kt | 2 + .../api/alignment/polar/tppa/TPPAState.kt | 1 + .../api/alignment/polar/tppa/TPPAStep.kt | 33 ++++++++++-- .../api/cameras/CameraCaptureExecutor.kt | 6 +-- .../nebulosa/api/cameras/CameraController.kt | 4 +- .../api/cameras/CameraExposureStep.kt | 5 +- .../nebulosa/api/cameras/CameraService.kt | 5 +- .../api/sequencer/SequencerController.kt | 6 +-- .../api/sequencer/SequencerExecutor.kt | 6 +-- .../api/sequencer/SequencerService.kt | 7 +-- .../api/wizard/flat/FlatWizardExecutor.kt | 5 +- .../app/alignment/alignment.component.html | 4 ++ .../src/app/alignment/alignment.component.ts | 23 ++++++--- desktop/src/shared/services/api.service.ts | 20 +++++--- desktop/src/shared/types/alignment.types.ts | 13 ++--- .../solving/LibAstrometryNetPlateSolver.kt | 2 +- .../batch/processing/AsyncJobLauncher.kt | 31 ++++++++++++ .../nebulosa/batch/processing/JobExecution.kt | 6 +++ .../nebulosa/batch/processing/JobExecutor.kt | 21 +++----- .../nebulosa/batch/processing/JobLauncher.kt | 4 ++ .../nebulosa/batch/processing/JobStatus.kt | 2 + .../nebulosa/batch/processing/SimpleJob.kt | 50 +++++++++++++++---- .../concurrency/cancel/CancellationToken.kt | 13 +++-- .../common/concurrency/latch/Pauseable.kt | 10 ++++ .../common/concurrency/latch/Pauser.kt | 14 +++--- nebulosa-common/src/test/kotlin/PauserTest.kt | 4 +- 39 files changed, 317 insertions(+), 222 deletions(-) delete mode 100644 api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVFinished.kt delete mode 100644 api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVGuidePulseElapsed.kt delete mode 100644 api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVInitialPauseElapsed.kt delete mode 100644 api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStarted.kt create mode 100644 nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauseable.kt diff --git a/api/src/main/kotlin/nebulosa/api/README.md b/api/src/main/kotlin/nebulosa/api/README.md index 696cfae80..f9c87e8ad 100644 --- a/api/src/main/kotlin/nebulosa/api/README.md +++ b/api/src/main/kotlin/nebulosa/api/README.md @@ -200,8 +200,7 @@ URL: `localhost:{PORT}/ws` ```json5 { - "camera": {}, - "mount": {}, + "id": "", "elapsedTime": 0, "stepCount": 0, "state": "SLEWING|SOLVING|SOLVED|COMPUTED|FAILED|FINISHED", diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt index fb3e05b10..a9ac9310e 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt @@ -6,10 +6,7 @@ import nebulosa.api.beans.converters.indi.DeviceOrEntityParam import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.guide.GuideOutput import nebulosa.indi.device.mount.Mount -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("polar-alignment") @@ -23,9 +20,9 @@ class PolarAlignmentController( @RequestBody body: DARVStartRequest, ) = polarAlignmentService.darvStart(camera, guideOutput, body) - @PutMapping("darv/{camera}/{guideOutput}/stop") - fun darvStop(@DeviceOrEntityParam camera: Camera, @DeviceOrEntityParam guideOutput: GuideOutput) { - polarAlignmentService.darvStop(camera, guideOutput) + @PutMapping("darv/{id}/stop") + fun darvStop(@PathVariable id: String) { + polarAlignmentService.darvStop(id) } @PutMapping("tppa/{camera}/{mount}/start") @@ -34,8 +31,18 @@ class PolarAlignmentController( @RequestBody body: TPPAStartRequest, ) = polarAlignmentService.tppaStart(camera, mount, body) - @PutMapping("tppa/{camera}/{mount}/stop") - fun tppaStop(@DeviceOrEntityParam camera: Camera, @DeviceOrEntityParam mount: Mount) { - polarAlignmentService.tppaStop(camera, mount) + @PutMapping("tppa/{id}/stop") + fun tppaStop(@PathVariable id: String) { + polarAlignmentService.tppaStop(id) + } + + @PutMapping("tppa/{id}/pause") + fun tppaPause(@PathVariable id: String) { + polarAlignmentService.tppaPause(id) + } + + @PutMapping("tppa/{id}/unpause") + fun tppaUnpause(@PathVariable id: String) { + polarAlignmentService.tppaUnpause(id) } } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt index bcb0a034a..1c43fa76e 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt @@ -21,8 +21,8 @@ class PolarAlignmentService( return darvExecutor.execute(camera, guideOutput, darvStartRequest) } - fun darvStop(camera: Camera, guideOutput: GuideOutput) { - darvExecutor.stop(camera, guideOutput) + fun darvStop(id: String) { + darvExecutor.stop(id) } fun tppaStart(camera: Camera, mount: Mount, tppaStartRequest: TPPAStartRequest): String { @@ -31,7 +31,15 @@ class PolarAlignmentService( return tppaExecutor.execute(camera, mount, tppaStartRequest) } - fun tppaStop(camera: Camera, mount: Mount) { - tppaExecutor.stop(camera, mount) + fun tppaStop(id: String) { + tppaExecutor.stop(id) + } + + fun tppaPause(id: String) { + tppaExecutor.pause(id) + } + + fun tppaUnpause(id: String) { + tppaExecutor.unpause(id) } } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVEvent.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVEvent.kt index 25f6d1696..2b768a6c1 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVEvent.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVEvent.kt @@ -2,15 +2,11 @@ package nebulosa.api.alignment.polar.darv import nebulosa.api.messages.MessageEvent import nebulosa.guiding.GuideDirection -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.guide.GuideOutput import java.time.Duration sealed interface DARVEvent : MessageEvent { - val camera: Camera - - val guideOutput: GuideOutput + val id: String val remainingTime: Duration @@ -22,4 +18,42 @@ sealed interface DARVEvent : MessageEvent { override val eventName get() = "DARV.ELAPSED" + + data class Started( + override val id: String, + override val remainingTime: Duration, + override val direction: GuideDirection, + ) : DARVEvent { + + override val progress = 0.0 + override val state = DARVState.INITIAL_PAUSE + } + + data class Finished( + override val id: String, + ) : DARVEvent { + + override val remainingTime = Duration.ZERO!! + override val progress = 0.0 + override val state = DARVState.IDLE + override val direction = null + } + + data class InitialPauseElapsed( + override val id: String, + override val remainingTime: Duration, + override val progress: Double, + ) : DARVEvent { + + override val state = DARVState.INITIAL_PAUSE + override val direction = null + } + + data class GuidePulseElapsed( + override val id: String, + override val remainingTime: Duration, + override val progress: Double, + override val direction: GuideDirection, + override val state: DARVState, + ) : MessageEvent, DARVEvent } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt index 1bf73d431..dd6f5b7aa 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt @@ -24,15 +24,10 @@ class DARVExecutor( LOG.info { "starting DARV. camera=$camera, guideOutput=$guideOutput, request=$request" } - return with(DARVJob(camera, guideOutput, request)) { - subscribe(messageService::sendMessage) - register(jobLauncher.launch(this)) - id - } - } - - fun stop(camera: Camera, guideOutput: GuideOutput) { - stopWithAny(camera, guideOutput) + val darvJob = DARVJob(camera, guideOutput, request) + darvJob.subscribe(messageService::sendMessage) + register(jobLauncher.launch(darvJob)) + return darvJob.id } companion object { diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVFinished.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVFinished.kt deleted file mode 100644 index 0748956e8..000000000 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVFinished.kt +++ /dev/null @@ -1,16 +0,0 @@ -package nebulosa.api.alignment.polar.darv - -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.guide.GuideOutput -import java.time.Duration - -data class DARVFinished( - override val camera: Camera, - override val guideOutput: GuideOutput, -) : DARVEvent { - - override val remainingTime = Duration.ZERO!! - override val progress = 0.0 - override val state = DARVState.IDLE - override val direction = null -} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVGuidePulseElapsed.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVGuidePulseElapsed.kt deleted file mode 100644 index 580cb0afc..000000000 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVGuidePulseElapsed.kt +++ /dev/null @@ -1,16 +0,0 @@ -package nebulosa.api.alignment.polar.darv - -import nebulosa.api.messages.MessageEvent -import nebulosa.guiding.GuideDirection -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.guide.GuideOutput -import java.time.Duration - -data class DARVGuidePulseElapsed( - override val camera: Camera, - override val guideOutput: GuideOutput, - override val remainingTime: Duration, - override val progress: Double, - override val direction: GuideDirection, - override val state: DARVState, -) : MessageEvent, DARVEvent diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVInitialPauseElapsed.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVInitialPauseElapsed.kt deleted file mode 100644 index cc17a87a1..000000000 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVInitialPauseElapsed.kt +++ /dev/null @@ -1,16 +0,0 @@ -package nebulosa.api.alignment.polar.darv - -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.guide.GuideOutput -import java.time.Duration - -data class DARVInitialPauseElapsed( - override val camera: Camera, - override val guideOutput: GuideOutput, - override val remainingTime: Duration, - override val progress: Double, -) : DARVEvent { - - override val state = DARVState.INITIAL_PAUSE - override val direction = null -} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt index 47dbe0e1d..04a268396 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt @@ -58,11 +58,11 @@ data class DARVJob( } override fun beforeJob(jobExecution: JobExecution) { - onNext(DARVStarted(camera, guideOutput, request.capture.exposureDelay, direction)) + onNext(DARVEvent.Started(id, request.capture.exposureDelay, direction)) } override fun afterJob(jobExecution: JobExecution) { - onNext(DARVFinished(camera, guideOutput)) + onNext(DARVEvent.Finished(id)) } override fun onExposureFinished(step: CameraExposureStep, stepExecution: StepExecution) { @@ -74,13 +74,13 @@ data class DARVJob( val remainingTime = stepExecution.context.getDuration(DelayStep.REMAINING_TIME) val progress = stepExecution.context.getDouble(DelayStep.PROGRESS) val state = if (direction == this.direction) DARVState.FORWARD else DARVState.BACKWARD - onNext(DARVGuidePulseElapsed(camera, guideOutput, remainingTime, progress, direction, state)) + onNext(DARVEvent.GuidePulseElapsed(id, remainingTime, progress, direction, state)) } override fun onDelayElapsed(step: DelayStep, stepExecution: StepExecution) { val remainingTime = stepExecution.context.getDuration(DelayStep.REMAINING_TIME) val progress = stepExecution.context.getDouble(DelayStep.PROGRESS) - onNext(DARVInitialPauseElapsed(camera, guideOutput, remainingTime, progress)) + onNext(DARVEvent.InitialPauseElapsed(id, remainingTime, progress)) } override fun contains(data: Any): Boolean { diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStarted.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStarted.kt deleted file mode 100644 index 066e6f704..000000000 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStarted.kt +++ /dev/null @@ -1,17 +0,0 @@ -package nebulosa.api.alignment.polar.darv - -import nebulosa.guiding.GuideDirection -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.guide.GuideOutput -import java.time.Duration - -data class DARVStarted( - override val camera: Camera, - override val guideOutput: GuideOutput, - override val remainingTime: Duration, - override val direction: GuideDirection, -) : DARVEvent { - - override val progress = 0.0 - override val state = DARVState.INITIAL_PAUSE -} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt index d193b6b59..c16deed1b 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt @@ -4,17 +4,13 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize import nebulosa.api.beans.converters.angle.DeclinationSerializer import nebulosa.api.beans.converters.angle.RightAscensionSerializer import nebulosa.api.messages.MessageEvent -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.mount.Mount import nebulosa.math.Angle import java.time.Duration import kotlin.math.hypot sealed interface TPPAEvent : MessageEvent { - val camera: Camera - - val mount: Mount? + val id: String val state: TPPAState @@ -47,8 +43,7 @@ sealed interface TPPAEvent : MessageEvent { get() = "TPPA.ELAPSED" data class Slewing( - override val camera: Camera, - override val mount: Mount?, + override val id: String, override val stepCount: Int, override val elapsedTime: Duration, @field:JsonSerialize(using = RightAscensionSerializer::class) override val rightAscension: Angle, @@ -59,8 +54,7 @@ sealed interface TPPAEvent : MessageEvent { } data class Solving( - override val camera: Camera, - override val mount: Mount?, + override val id: String, override val stepCount: Int, override val elapsedTime: Duration, ) : TPPAEvent { @@ -69,8 +63,7 @@ sealed interface TPPAEvent : MessageEvent { } data class Solved( - override val camera: Camera, - override val mount: Mount?, + override val id: String, override val stepCount: Int, override val elapsedTime: Duration, @field:JsonSerialize(using = RightAscensionSerializer::class) override val rightAscension: Angle, @@ -80,9 +73,17 @@ sealed interface TPPAEvent : MessageEvent { override val state = TPPAState.SOLVED } + data class Paused( + override val id: String, + override val stepCount: Int, + override val elapsedTime: Duration, + ) : TPPAEvent { + + override val state = TPPAState.PAUSED + } + data class Computed( - override val camera: Camera, - override val mount: Mount?, + override val id: String, override val stepCount: Int, override val elapsedTime: Duration, @field:JsonSerialize(using = DeclinationSerializer::class) override val azimuthError: Angle, @@ -96,8 +97,7 @@ sealed interface TPPAEvent : MessageEvent { } data class Failed( - override val camera: Camera, - override val mount: Mount?, + override val id: String, override val stepCount: Int, override val elapsedTime: Duration, ) : TPPAEvent { @@ -106,8 +106,7 @@ sealed interface TPPAEvent : MessageEvent { } data class Finished( - override val camera: Camera, - override val mount: Mount?, + override val id: String, ) : TPPAEvent { override val stepCount = 0 diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt index 425aafcaa..5c941ebf5 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt @@ -28,15 +28,10 @@ class TPPAExecutor( val solver = plateSolverService.solverFor(request.plateSolver) - return with(TPPAJob(camera, request, solver, mount)) { - subscribe(this@TPPAExecutor) - register(jobLauncher.launch(this)) - id - } - } - - fun stop(camera: Camera, mount: Mount) { - stopWithAny(camera, mount) + val tppaJob = TPPAJob(camera, request, solver, mount) + tppaJob.subscribe(this) + register(jobLauncher.launch(tppaJob)) + return tppaJob.id } override fun accept(event: MessageEvent) { diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt index eadebae4f..b98da8fcf 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt @@ -43,39 +43,43 @@ data class TPPAJob( } override fun slewStarted(step: TPPAStep, rightAscension: Angle, declination: Angle) { - onNext(TPPAEvent.Slewing(step.camera, step.mount, step.stepCount, step.elapsedTime, rightAscension, declination)) + onNext(TPPAEvent.Slewing(id, step.stepCount, step.elapsedTime, rightAscension, declination)) } override fun solverStarted(step: TPPAStep) { - onNext(TPPAEvent.Solving(step.camera, step.mount, step.stepCount, step.elapsedTime)) + onNext(TPPAEvent.Solving(id, step.stepCount, step.elapsedTime)) } override fun solverFinished(step: TPPAStep, rightAscension: Angle, declination: Angle) { - onNext(TPPAEvent.Solved(step.camera, step.mount, step.stepCount, step.elapsedTime, rightAscension, declination)) + onNext(TPPAEvent.Solved(id, step.stepCount, step.elapsedTime, rightAscension, declination)) + } + + override fun polarAlignmentPaused(step: TPPAStep) { + onNext(TPPAEvent.Paused(id, step.stepCount, step.elapsedTime)) } override fun polarAlignmentComputed(step: TPPAStep, azimuth: Angle, altitude: Angle) { - val azimuthErrorDir = when { + val azimuthErrorDirection = when { azimuth > 0 -> if (latitude > 0) "🠔 Move LEFT/WEST" else "🠔 Move LEFT/EAST" azimuth < 0 -> if (latitude > 0) "Move RIGHT/EAST 🠖" else "Move RIGHT/WEST 🠖" else -> "" } - val altitudeErrorDir = when { + val altitudeErrorDirection = when { altitude > 0 -> if (latitude > 0) "🠗 Move DOWN" else "Move UP 🠕" altitude < 0 -> if (latitude > 0) "Move UP 🠕" else "🠗 Move DOWN" else -> "" } - onNext(TPPAEvent.Computed(step.camera, step.mount, step.stepCount, step.elapsedTime, azimuth, altitude, azimuthErrorDir, altitudeErrorDir)) + onNext(TPPAEvent.Computed(id, step.stepCount, step.elapsedTime, azimuth, altitude, azimuthErrorDirection, altitudeErrorDirection)) } override fun solverFailed(step: TPPAStep) { - onNext(TPPAEvent.Failed(step.camera, step.mount, step.stepCount, step.elapsedTime)) + onNext(TPPAEvent.Failed(id, step.stepCount, step.elapsedTime)) } override fun polarAlignmentFinished(step: TPPAStep, aborted: Boolean) { - onNext(TPPAEvent.Finished(step.camera, step.mount)) + onNext(TPPAEvent.Finished(id)) } override fun contains(data: Any): Boolean { diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAListener.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAListener.kt index 04996ba03..a14e666c2 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAListener.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAListener.kt @@ -10,6 +10,8 @@ interface TPPAListener { fun solverFinished(step: TPPAStep, rightAscension: Angle, declination: Angle) + fun polarAlignmentPaused(step: TPPAStep) + fun polarAlignmentComputed(step: TPPAStep, azimuth: Angle, altitude: Angle) fun solverFailed(step: TPPAStep) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAState.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAState.kt index 67e933b8b..62f507b39 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAState.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAState.kt @@ -4,6 +4,7 @@ enum class TPPAState { SLEWING, SOLVING, SOLVED, + PAUSED, COMPUTED, FAILED, FINISHED, diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt index dfae22905..00e395ed7 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt @@ -10,7 +10,7 @@ import nebulosa.batch.processing.JobExecution import nebulosa.batch.processing.Step import nebulosa.batch.processing.StepExecution import nebulosa.batch.processing.StepResult -import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.common.concurrency.latch.Pauseable import nebulosa.common.time.Stopwatch import nebulosa.fits.Fits import nebulosa.imaging.Image @@ -30,17 +30,17 @@ data class TPPAStep( private val longitude: Angle = mount!!.longitude, private val latitude: Angle = mount!!.latitude, private val cameraRequest: CameraStartCaptureRequest = request.capture, -) : Step { +) : Step, Pauseable { private val cameraExposureStep = CameraExposureStep(camera, cameraRequest) private val alignment = ThreePointPolarAlignment(solver, longitude, latitude) private val listeners = LinkedHashSet() private val stopwatch = Stopwatch() - private val cancellationToken = CancellationToken() @Volatile private var image: Image? = null @Volatile private var mountSlewStep: MountSlewStep? = null @Volatile private var noSolutionAttempts = 0 + @Volatile private var stepExecution: StepExecution? = null val stepCount get() = alignment.state @@ -78,16 +78,28 @@ data class TPPAStep( stopwatch.stop() - listeners.forEach { it.polarAlignmentFinished(this, cancellationToken.isCancelled) } + listeners.forEach { it.polarAlignmentFinished(this, jobExecution.cancellationToken.isCancelled) } } override fun execute(stepExecution: StepExecution): StepResult { + val cancellationToken = stepExecution.jobExecution.cancellationToken + if (cancellationToken.isCancelled) return StepResult.FINISHED LOG.debug { "executing TPPA. camera=$camera, mount=$mount, state=${alignment.state}" } + this.stepExecution = stepExecution + + if (cancellationToken.isPaused) { + listeners.forEach { it.polarAlignmentPaused(this) } + cancellationToken.waitIfPaused() + } + + if (cancellationToken.isCancelled) return StepResult.FINISHED + stopwatch.start() + // Mount slew step. if (mount != null) { val stepDistance = if (request.eastDirection) request.stepDistance else -request.stepDistance @@ -103,6 +115,7 @@ data class TPPAStep( listeners.forEach { it.solverStarted(this) } + // Camera capture step. cameraExposureStep.execute(stepExecution) if (!cancellationToken.isCancelled) { @@ -111,6 +124,7 @@ data class TPPAStep( val radius = if (mount == null) 0.0 else ThreePointPolarAlignment.DEFAULT_RADIUS + // Polar alignment step. val result = alignment.align( savedPath, image!!, mount?.rightAscension ?: 0.0, mount?.declination ?: 0.0, radius, request.compensateRefraction, cancellationToken @@ -149,11 +163,20 @@ data class TPPAStep( } override fun stop(mayInterruptIfRunning: Boolean) { - cancellationToken.cancel(mayInterruptIfRunning) mountSlewStep?.stop(mayInterruptIfRunning) cameraExposureStep.stop(mayInterruptIfRunning) } + override val isPaused + get() = stepExecution?.jobExecution?.cancellationToken?.isPaused ?: false + + override fun pause() { + stopwatch.stop() + } + + override fun unpause() { + } + companion object { @JvmStatic private val LOG = loggerFor() diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt index ce1f21eae..7d0b97eb8 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt @@ -16,8 +16,7 @@ class CameraCaptureExecutor( override val jobLauncher: JobLauncher, ) : JobExecutor() { - @Synchronized - fun execute(camera: Camera, request: CameraStartCaptureRequest) { + fun execute(camera: Camera, request: CameraStartCaptureRequest): String { check(camera.connected) { "camera is not connected" } check(findJobExecutionWithAny(camera) == null) { "Camera Capture job is already running" } @@ -26,10 +25,11 @@ class CameraCaptureExecutor( val cameraCaptureJob = CameraCaptureJob(camera, request, guider) cameraCaptureJob.subscribe(messageService::sendMessage) register(jobLauncher.launch(cameraCaptureJob)) + return cameraCaptureJob.id } fun stop(camera: Camera) { - stopWithAny(camera) + findJobExecutionWithAny(camera)?.also { jobLauncher.stop(it) } } companion object { diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt index c0f91c5b2..b62e6ee42 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt @@ -54,9 +54,7 @@ class CameraController( fun startCapture( @DeviceOrEntityParam camera: Camera, @RequestBody body: CameraStartCaptureRequest, - ) { - cameraService.startCapture(camera, body) - } + ) = cameraService.startCapture(camera, body) @PutMapping("{camera}/capture/abort") fun abortCapture(@DeviceOrEntityParam camera: Camera) { diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt index 59936ddd8..c1ad00a40 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt @@ -84,7 +84,6 @@ data class CameraExposureStep( } override fun beforeJob(jobExecution: JobExecution) { - camera.enableBlob() exposureCount = jobExecution.context.getInt(EXPOSURE_COUNT, exposureCount) captureElapsedTime = jobExecution.context.getDuration(CAPTURE_ELAPSED_TIME, captureElapsedTime) jobExecution.context.populateExecutionContext(Duration.ZERO, estimatedCaptureTime, 0.0) @@ -112,7 +111,7 @@ data class CameraExposureStep( override fun stop(mayInterruptIfRunning: Boolean) { LOG.info("stopping camera exposure. camera={}", camera) camera.abortCapture() - camera.disableBlob() + // camera.disableBlob() aborted = true latch.reset() } @@ -140,6 +139,8 @@ data class CameraExposureStep( stepExecution.context[EXPOSURE_COUNT] = ++exposureCount + camera.enableBlob() + listeners.forEach { it.onExposureStarted(this, stepExecution) } if (request.width > 0 && request.height > 0) { diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt index 2d88a4cb4..987940b1a 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt @@ -29,15 +29,16 @@ class CameraService( } @Synchronized - fun startCapture(camera: Camera, request: CameraStartCaptureRequest) { + fun startCapture(camera: Camera, request: CameraStartCaptureRequest): String { val savePath = request.savePath ?.takeIf { "$it".isNotBlank() && it.exists() && it.isDirectory() } ?: Path.of("$capturesPath", camera.name, request.frameType.name) - cameraCaptureExecutor + return cameraCaptureExecutor .execute(camera, request.copy(savePath = savePath)) } + @Synchronized fun abortCapture(camera: Camera) { cameraCaptureExecutor.stop(camera) } diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt index 2c5946eb1..579ff2f82 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt @@ -18,12 +18,10 @@ class SequencerController( fun startSequencer( @DeviceOrEntityParam camera: Camera, @RequestBody @Valid body: SequencePlanRequest, - ) { - sequencerService.startSequencer(camera, body) - } + ) = sequencerService.start(camera, body) @PutMapping("{camera}/stop") fun stopSequencer(@DeviceOrEntityParam camera: Camera) { - sequencerService.stopSequencer(camera) + sequencerService.stop(camera) } } diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt index 079c67c4e..f4af5f833 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt @@ -18,11 +18,10 @@ class SequencerExecutor( override val jobLauncher: JobLauncher, ) : JobExecutor() { - @Synchronized fun execute( camera: Camera, request: SequencePlanRequest, wheel: FilterWheel? = null, focuser: Focuser? = null, - ) { + ): String { check(findJobExecutionWithAny(camera) == null) { "job is already running" } LOG.info { "starting sequencer. camera=$camera, wheel=$wheel, focuser=$focuser, request=$request" } @@ -31,10 +30,11 @@ class SequencerExecutor( sequencerJob.subscribe(messageService::sendMessage) sequencerJob.initialize() register(jobLauncher.launch(sequencerJob)) + return sequencerJob.id } fun stop(camera: Camera) { - stopWithAny(camera) + findJobExecutionWithAny(camera)?.also { jobLauncher.stop(it) } } companion object { diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt index 18ea4f3e6..b3840bcfe 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt @@ -13,16 +13,17 @@ class SequencerService( ) { @Synchronized - fun startSequencer(camera: Camera, request: SequencePlanRequest) { + fun start(camera: Camera, request: SequencePlanRequest): String { val savePath = request.savePath ?.takeIf { "$it".isNotBlank() && it.exists() && it.isDirectory() } ?: Path.of("$sequencesPath", (System.currentTimeMillis() / 1000).toString()) - sequencerExecutor + return sequencerExecutor .execute(camera, request.copy(savePath = savePath)) } - fun stopSequencer(camera: Camera) { + @Synchronized + fun stop(camera: Camera) { sequencerExecutor.stop(camera) } } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt index 52275437a..e79c4038b 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt @@ -14,7 +14,7 @@ class FlatWizardExecutor( override val jobLauncher: JobLauncher, ) : JobExecutor() { - fun execute(camera: Camera, request: FlatWizardRequest) { + fun execute(camera: Camera, request: FlatWizardRequest): String { check(camera.connected) { "camera is not connected" } check(findJobExecutionWithAny(camera) == null) { "job is already running for camera: [${camera.name}]" } @@ -23,10 +23,11 @@ class FlatWizardExecutor( val flatWizardJob = FlatWizardJob(camera, request) flatWizardJob.subscribe(messageService::sendMessage) register(jobLauncher.launch(flatWizardJob)) + return flatWizardJob.id } fun stop(camera: Camera) { - stopWithAny(camera) + findJobExecutionWithAny(camera)?.also { jobLauncher.stop(it) } } companion object { diff --git a/desktop/src/app/alignment/alignment.component.html b/desktop/src/app/alignment/alignment.component.html index c2baffd94..abb3f771e 100644 --- a/desktop/src/app/alignment/alignment.component.html +++ b/desktop/src/app/alignment/alignment.component.html @@ -104,6 +104,10 @@
+ + { - if (event.camera.name === this.camera.name && - (!event.mount || event.mount.name === this.mount.name)) { + if (event.id === this.id) { ngZone.run(() => { this.status = event.state this.running = event.state !== 'FINISHED' @@ -190,8 +190,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { }) electron.on('DARV.ELAPSED', event => { - if (event.camera.name === this.camera.name && - event.guideOutput.name === this.guideOutput.name) { + if (event.id === this.id) { ngZone.run(() => { this.status = event.state this.remainingTime = event.remainingTime @@ -301,21 +300,29 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { this.darvRequest.capture.exposureTime = this.darvRequest.exposureTime * 1000000 this.darvRequest.capture.exposureDelay = this.darvRequest.initialPause await this.openCameraImage() - await this.api.darvStart(this.camera, this.guideOutput, this.darvRequest) + this.id = await this.api.darvStart(this.camera, this.guideOutput, this.darvRequest) } darvStop() { - this.api.darvStop(this.camera, this.guideOutput) + this.api.darvStop(this.id) } async tppaStart() { this.alignmentMethod = 'TPPA' await this.openCameraImage() - await this.api.tppaStart(this.camera, this.mount, this.tppaRequest) + this.id = await this.api.tppaStart(this.camera, this.mount, this.tppaRequest) + } + + tppaPause() { + this.api.tppaPause(this.id) + } + + tppaUnpause() { + this.api.tppaUnpause(this.id) } tppaStop() { - this.api.tppaStop(this.camera, this.mount) + this.api.tppaStop(this.id) } openCameraImage() { diff --git a/desktop/src/shared/services/api.service.ts b/desktop/src/shared/services/api.service.ts index b395d6d95..a45bd345f 100644 --- a/desktop/src/shared/services/api.service.ts +++ b/desktop/src/shared/services/api.service.ts @@ -532,21 +532,29 @@ export class ApiService { // DARV darvStart(camera: Camera, guideOutput: GuideOutput, data: DARVStart) { - return this.http.put(`polar-alignment/darv/${camera.name}/${guideOutput.name}/start`, data) + return this.http.put(`polar-alignment/darv/${camera.name}/${guideOutput.name}/start`, data) } - darvStop(camera: Camera, guideOutput: GuideOutput) { - return this.http.put(`polar-alignment/darv/${camera.name}/${guideOutput.name}/stop`) + darvStop(id: string) { + return this.http.put(`polar-alignment/darv/${id}/stop`) } // TPPA tppaStart(camera: Camera, mount: Mount, data: TPPAStart) { - return this.http.put(`polar-alignment/tppa/${camera.name}/${mount.name}/start`, data) + return this.http.put(`polar-alignment/tppa/${camera.name}/${mount.name}/start`, data) } - tppaStop(camera: Camera, mount: Mount) { - return this.http.put(`polar-alignment/tppa/${camera.name}/${mount.name}/stop`) + tppaStop(id: string) { + return this.http.put(`polar-alignment/tppa/${id}/stop`) + } + + tppaPause(id: string) { + return this.http.put(`polar-alignment/tppa/${id}/pause`) + } + + tppaUnpause(id: string) { + return this.http.put(`polar-alignment/tppa/${id}/unpause`) } // SEQUENCER diff --git a/desktop/src/shared/types/alignment.types.ts b/desktop/src/shared/types/alignment.types.ts index 9f109932e..cd235a6c2 100644 --- a/desktop/src/shared/types/alignment.types.ts +++ b/desktop/src/shared/types/alignment.types.ts @@ -1,14 +1,13 @@ import { Angle } from './atlas.types' -import { Camera, CameraStartCapture } from './camera.types' -import { GuideDirection, GuideOutput } from './guider.types' -import { Mount } from './mount.types' +import { CameraStartCapture } from './camera.types' +import { GuideDirection } from './guider.types' import { PlateSolverOptions, PlateSolverType } from './settings.types' export type Hemisphere = 'NORTHERN' | 'SOUTHERN' export type DARVState = 'IDLE' | 'INITIAL_PAUSE' | 'FORWARD' | 'BACKWARD' -export type TPPAState = 'IDLE' | 'SLEWING' | 'SOLVING' | 'SOLVED' | 'COMPUTED' | 'FAILED' | 'FINISHED' +export type TPPAState = 'IDLE' | 'SLEWING' | 'SOLVING' | 'SOLVED' | 'PAUSED' | 'COMPUTED' | 'FAILED' | 'FINISHED' export type AlignmentMethod = 'DARV' | 'TPPA' @@ -45,8 +44,7 @@ export interface DARVStart { } export interface DARVElapsed extends MessageEvent { - camera: Camera - guideOutput: GuideOutput + id: string remainingTime: number progress: number state: DARVState @@ -64,8 +62,7 @@ export interface TPPAStart { } export interface TPPAElapsed extends MessageEvent { - camera: Camera - mount?: Mount + id: string elapsedTime: number stepCount: number state: TPPAState diff --git a/nebulosa-astrometrynet-jna/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LibAstrometryNetPlateSolver.kt b/nebulosa-astrometrynet-jna/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LibAstrometryNetPlateSolver.kt index b3bcf6ed8..8f69b51ab 100644 --- a/nebulosa-astrometrynet-jna/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LibAstrometryNetPlateSolver.kt +++ b/nebulosa-astrometrynet-jna/src/main/kotlin/nebulosa/astrometrynet/plate/solving/LibAstrometryNetPlateSolver.kt @@ -14,7 +14,7 @@ data class LibAstrometryNetPlateSolver(private val solver: LibAstrometryNet) : P path: Path?, image: Image?, centerRA: Angle, centerDEC: Angle, radius: Angle, downsampleFactor: Int, timeout: Duration?, - cancellationToken: CancellationToken?, + cancellationToken: CancellationToken, ): PlateSolution { return PlateSolution.NO_SOLUTION } diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt index 5dbfcf6c3..b1bd5fd85 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt @@ -2,6 +2,7 @@ package nebulosa.batch.processing import nebulosa.common.concurrency.cancel.CancellationListener import nebulosa.common.concurrency.cancel.CancellationSource +import nebulosa.common.concurrency.latch.Pauseable import nebulosa.log.debug import nebulosa.log.loggerFor import java.io.Closeable @@ -153,6 +154,36 @@ open class AsyncJobLauncher(private val executor: Executor) : JobLauncher, StepI } } + override fun pause(jobExecution: JobExecution) { + if (!jobExecution.isDone && !jobExecution.isPausing && !jobExecution.isPaused) { + val job = jobExecution.job + + if (job is Pauseable) { + jobExecution.status = JobStatus.PAUSING + job.pause() + } + + if (!jobExecution.cancellationToken.isDone) { + jobExecution.cancellationToken.pause() + } + } + } + + override fun unpause(jobExecution: JobExecution) { + if (!jobExecution.isDone && !jobExecution.isPausing && jobExecution.isPaused) { + val job = jobExecution.job + + if (job is Pauseable) { + job.unpause() + jobExecution.status = JobStatus.STARTED + } + + if (!jobExecution.cancellationToken.isDone) { + jobExecution.cancellationToken.unpause() + } + } + } + override fun intercept(chain: StepChain): StepResult { stepListeners.forEach { it.beforeStep(chain.stepExecution) } val result = chain.step.execute(chain.stepExecution) diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt index 40b067138..606795234 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt @@ -32,6 +32,12 @@ class JobExecution( inline val isStopped get() = status == JobStatus.STOPPED + inline val isPausing + get() = status == JobStatus.PAUSING + + inline val isPaused + get() = status == JobStatus.PAUSED + inline val isCompleted get() = status == JobStatus.COMPLETED diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecutor.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecutor.kt index 1bc04063b..e9f7567a3 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecutor.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecutor.kt @@ -25,10 +25,6 @@ abstract class JobExecutor { return null } - protected fun findJobExecutionWithAll(vararg data: Any): JobExecution? { - return findJobExecutionWith { data.all { it in this } } - } - protected fun findJobExecutionWithAny(vararg data: Any): JobExecution? { return findJobExecutionWith { data.any { it in this } } } @@ -37,22 +33,19 @@ abstract class JobExecutor { return jobExecutions.find { it.job.id == id } } - @Synchronized - protected fun stopWithAll(vararg data: Any) { - val jobExecution = findJobExecutionWithAll(*data) ?: return + fun stop(id: String) { + val jobExecution = findJobExecution(id) ?: return jobLauncher.stop(jobExecution) } - @Synchronized - protected fun stopWithAny(vararg data: Any) { - val jobExecution = findJobExecutionWithAny(*data) ?: return - jobLauncher.stop(jobExecution) + fun pause(id: String) { + val jobExecution = findJobExecution(id) ?: return + jobLauncher.pause(jobExecution) } - @Synchronized - fun stop(id: String) { + fun unpause(id: String) { val jobExecution = findJobExecution(id) ?: return - jobLauncher.stop(jobExecution) + jobLauncher.unpause(jobExecution) } fun isRunning(id: String): Boolean { diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobLauncher.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobLauncher.kt index fa00440a7..71683c80a 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobLauncher.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobLauncher.kt @@ -19,4 +19,8 @@ interface JobLauncher : Collection, Stoppable { fun launch(job: Job, executionContext: ExecutionContext? = null): JobExecution fun stop(jobExecution: JobExecution, mayInterruptIfRunning: Boolean = true) + + fun pause(jobExecution: JobExecution) + + fun unpause(jobExecution: JobExecution) } diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobStatus.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobStatus.kt index 005aa08e2..306c90e7e 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobStatus.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobStatus.kt @@ -5,6 +5,8 @@ enum class JobStatus { STARTED, STOPPING, STOPPED, + PAUSING, + PAUSED, FAILED, COMPLETED, ABANDONED, diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleJob.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleJob.kt index cc6e4b3a5..adf372f2d 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleJob.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleJob.kt @@ -1,8 +1,9 @@ package nebulosa.batch.processing +import nebulosa.common.concurrency.latch.Pauseable import java.util.* -abstract class SimpleJob : Job, Iterable { +abstract class SimpleJob : Job, Pauseable, Iterable { private val steps = ArrayList() @@ -17,7 +18,7 @@ abstract class SimpleJob : Job, Iterable { override val id = UUID.randomUUID().toString() @Volatile private var position = 0 - @Volatile private var end = false + @Volatile private var isEnded = false protected fun register(step: Step): Boolean { return steps.add(step) @@ -31,31 +32,58 @@ abstract class SimpleJob : Job, Iterable { return steps.clear() } - override fun hasNext(jobExecution: JobExecution): Boolean { - return !end && position < steps.size + final override fun hasNext(jobExecution: JobExecution): Boolean { + return !isEnded && position < steps.size } - override fun next(jobExecution: JobExecution): Step { - check(!end) { "this job is ended" } + final override fun next(jobExecution: JobExecution): Step { + check(!isEnded) { "this job is ended" } return steps[position++] } - override fun stop(mayInterruptIfRunning: Boolean) { - if (end) return + final override fun stop(mayInterruptIfRunning: Boolean) { + if (isEnded) return - end = true + isEnded = true if (position in 1..steps.size) { steps[position - 1].stop(mayInterruptIfRunning) } } + final override val isPaused + get() = steps.any { it !== this && it is Pauseable && it.isPaused } + + final override fun pause() { + if (isEnded) return + + if (position in 1..steps.size) { + val step = steps[position - 1] + + if (step is Pauseable) { + step.pause() + } + } + } + + final override fun unpause() { + if (isEnded) return + + if (position in 1..steps.size) { + val step = steps[position - 1] + + if (step is Pauseable) { + step.unpause() + } + } + } + fun reset() { - end = false + isEnded = false position = 0 } - override fun iterator(): Iterator { + final override fun iterator(): Iterator { return steps.iterator() } diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt index 31425330e..285667774 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt @@ -1,25 +1,25 @@ package nebulosa.common.concurrency.cancel +import nebulosa.common.concurrency.latch.Pauser import java.io.Closeable import java.util.concurrent.CompletableFuture import java.util.concurrent.Future import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean import java.util.function.Consumer typealias CancellationListener = Consumer -class CancellationToken private constructor(private val completable: CompletableFuture?) : Closeable, Future { +class CancellationToken private constructor(private val completable: CompletableFuture?) : Pauser(), Closeable, + Future { constructor() : this(CompletableFuture()) private val listeners = LinkedHashSet() - private val completed = AtomicBoolean() init { completable?.whenComplete { source, _ -> synchronized(this) { - completed.set(true) + unpause() if (source != null) { listeners.forEach { it.accept(source) } @@ -33,7 +33,7 @@ class CancellationToken private constructor(private val completable: Completable @Synchronized fun listen(listener: CancellationListener) { if (completable != null) { - if (completed.get() || isDone) { + if (isDone) { listener.accept(CancellationSource.Listen) } else { listeners.add(listener) @@ -56,6 +56,7 @@ class CancellationToken private constructor(private val completable: Completable @Synchronized fun cancel(source: CancellationSource): Boolean { + unpause() completable?.complete(source) ?: return false return true } @@ -77,6 +78,8 @@ class CancellationToken private constructor(private val completable: Completable } override fun close() { + super.close() + if (!isDone) { completable?.complete(CancellationSource.Close) } diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauseable.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauseable.kt new file mode 100644 index 000000000..d0f0b7089 --- /dev/null +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauseable.kt @@ -0,0 +1,10 @@ +package nebulosa.common.concurrency.latch + +interface Pauseable { + + val isPaused: Boolean + + fun pause() + + fun unpause() +} diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauser.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauser.kt index 3bc30e859..0e3f2a106 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauser.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauser.kt @@ -4,20 +4,20 @@ import java.io.Closeable import java.time.Duration import java.util.concurrent.TimeUnit -class Pauser : Closeable { +open class Pauser : Pauseable, Closeable { private val latch = CountUpDownLatch() - val isPaused + final override val isPaused get() = !latch.get() - fun pause() { + final override fun pause() { if (latch.get()) { latch.countUp(1) } } - fun unpause() { + final override fun unpause() { if (!latch.get()) { latch.reset() } @@ -27,15 +27,15 @@ class Pauser : Closeable { unpause() } - fun waitWhileIsPaused() { + fun waitIfPaused() { latch.await() } - fun waitWhileIsPaused(timeout: Long, unit: TimeUnit): Boolean { + fun waitIfPaused(timeout: Long, unit: TimeUnit): Boolean { return latch.await(timeout, unit) } - fun waitWhileIsPaused(timeout: Duration): Boolean { + fun waitIfPaused(timeout: Duration): Boolean { return latch.await(timeout) } } diff --git a/nebulosa-common/src/test/kotlin/PauserTest.kt b/nebulosa-common/src/test/kotlin/PauserTest.kt index 9e523daba..102d1ef07 100644 --- a/nebulosa-common/src/test/kotlin/PauserTest.kt +++ b/nebulosa-common/src/test/kotlin/PauserTest.kt @@ -14,7 +14,7 @@ class PauserTest : StringSpec() { pauser.pause() pauser.isPaused.shouldBeTrue() thread { Thread.sleep(1000); pauser.unpause() } - pauser.waitWhileIsPaused() + pauser.waitIfPaused() pauser.isPaused.shouldBeFalse() } "pause and not wait for unpause" { @@ -23,7 +23,7 @@ class PauserTest : StringSpec() { pauser.pause() pauser.isPaused.shouldBeTrue() thread { Thread.sleep(1000); pauser.unpause() } - pauser.waitWhileIsPaused(500, TimeUnit.MILLISECONDS).shouldBeFalse() + pauser.waitIfPaused(500, TimeUnit.MILLISECONDS).shouldBeFalse() pauser.isPaused.shouldBeTrue() } } From 91e6d7892881b8c8c3eff2a3a841f4cf18acb0da Mon Sep 17 00:00:00 2001 From: tiagohm Date: Mon, 12 Feb 2024 16:10:43 -0300 Subject: [PATCH 12/87] [api][desktop]: Implement Three Point Polar Alignment --- .../api/alignment/polar/tppa/TPPAStep.kt | 12 ++++++--- .../app/alignment/alignment.component.html | 26 +++++++++++++------ .../src/app/alignment/alignment.component.ts | 6 ++++- desktop/src/app/image/image.component.html | 4 +-- desktop/src/shared/pipes/enum.pipe.ts | 1 + desktop/src/shared/types/alignment.types.ts | 2 +- .../batch/processing/AsyncJobLauncher.kt | 6 ++--- .../nebulosa/batch/processing/JobExecution.kt | 5 +--- .../nebulosa/batch/processing/JobStatus.kt | 1 - .../concurrency/cancel/CancellationToken.kt | 2 +- 10 files changed, 40 insertions(+), 25 deletions(-) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt index 00e395ed7..84c43c79a 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt @@ -36,6 +36,7 @@ data class TPPAStep( private val alignment = ThreePointPolarAlignment(solver, longitude, latitude) private val listeners = LinkedHashSet() private val stopwatch = Stopwatch() + private val stepDistances = DoubleArray(2) { if (request.eastDirection) request.stepDistance else -request.stepDistance } @Volatile private var image: Image? = null @Volatile private var mountSlewStep: MountSlewStep? = null @@ -101,13 +102,12 @@ data class TPPAStep( // Mount slew step. if (mount != null) { - val stepDistance = if (request.eastDirection) request.stepDistance else -request.stepDistance - - if (alignment.state in 1..2) { - val step = MountSlewStep(mount, mount.rightAscension + stepDistance.deg, mount.declination) + if (alignment.state in 1..2 && stepDistances[alignment.state - 1] != 0.0) { + val step = MountSlewStep(mount, mount.rightAscension + stepDistances[alignment.state - 1].deg, mount.declination) mountSlewStep = step listeners.forEach { it.slewStarted(this, step.rightAscension, step.declination) } step.executeSingle(stepExecution) + stepDistances[alignment.state - 1] = 0.0 } } @@ -151,10 +151,14 @@ data class TPPAStep( } } is ThreePointPolarAlignmentResult.Measured -> { + noSolutionAttempts = 0 + listeners.forEach { it.solverFinished(this, result.rightAscension, result.declination) it.polarAlignmentComputed(this, result.azimuth, result.altitude) } + + return StepResult.CONTINUABLE } } } diff --git a/desktop/src/app/alignment/alignment.component.html b/desktop/src/app/alignment/alignment.component.html index abb3f771e..0364e7083 100644 --- a/desktop/src/app/alignment/alignment.component.html +++ b/desktop/src/app/alignment/alignment.component.html @@ -35,20 +35,28 @@ {{ status | enum | lowercase }} - + {{ darvDirection }} - + {{ remainingTime | exposureTime }} - + {{ progress | percent:'1.1-1' }} - + {{ elapsedTime | exposureTime }} + + {{ tppaRightAscension }} + + + {{ tppaDeclination }} +
@@ -104,10 +112,12 @@
- - + + { if (event.id === this.id) { ngZone.run(() => { - this.status = event.state + if (this.status !== 'PAUSING' || event.state === 'PAUSED') { + this.status = event.state + } + this.running = event.state !== 'FINISHED' this.elapsedTime = event.elapsedTime @@ -314,6 +317,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } tppaPause() { + this.status = 'PAUSING' this.api.tppaPause(this.id) } diff --git a/desktop/src/app/image/image.component.html b/desktop/src/app/image/image.component.html index a2fa5cffe..a442ce890 100644 --- a/desktop/src/app/image/image.component.html +++ b/desktop/src/app/image/image.component.html @@ -211,7 +211,7 @@
-
+
@@ -220,7 +220,7 @@ + [text]="true" size="small" />
diff --git a/desktop/src/shared/pipes/enum.pipe.ts b/desktop/src/shared/pipes/enum.pipe.ts index 68b2f8aaf..48fb830c2 100644 --- a/desktop/src/shared/pipes/enum.pipe.ts +++ b/desktop/src/shared/pipes/enum.pipe.ts @@ -333,6 +333,7 @@ export class EnumPipe implements PipeTransform { 'COMPUTED': 'Computed', 'FAILED': 'Failed', 'FINISHED': 'Finished', + 'PAUSING': 'Pausing', // Camera Exposure. 'SETTLING': 'Settling', 'WAITING': 'Waiting', diff --git a/desktop/src/shared/types/alignment.types.ts b/desktop/src/shared/types/alignment.types.ts index cd235a6c2..90c9333c9 100644 --- a/desktop/src/shared/types/alignment.types.ts +++ b/desktop/src/shared/types/alignment.types.ts @@ -7,7 +7,7 @@ export type Hemisphere = 'NORTHERN' | 'SOUTHERN' export type DARVState = 'IDLE' | 'INITIAL_PAUSE' | 'FORWARD' | 'BACKWARD' -export type TPPAState = 'IDLE' | 'SLEWING' | 'SOLVING' | 'SOLVED' | 'PAUSED' | 'COMPUTED' | 'FAILED' | 'FINISHED' +export type TPPAState = 'IDLE' | 'SLEWING' | 'SOLVING' | 'SOLVED' | 'PAUSING' | 'PAUSED' | 'COMPUTED' | 'FAILED' | 'FINISHED' export type AlignmentMethod = 'DARV' | 'TPPA' diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt index b1bd5fd85..b489d0ca6 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt @@ -155,11 +155,11 @@ open class AsyncJobLauncher(private val executor: Executor) : JobLauncher, StepI } override fun pause(jobExecution: JobExecution) { - if (!jobExecution.isDone && !jobExecution.isPausing && !jobExecution.isPaused) { + if (!jobExecution.isDone && !jobExecution.isPaused) { val job = jobExecution.job if (job is Pauseable) { - jobExecution.status = JobStatus.PAUSING + jobExecution.status = JobStatus.PAUSED job.pause() } @@ -170,7 +170,7 @@ open class AsyncJobLauncher(private val executor: Executor) : JobLauncher, StepI } override fun unpause(jobExecution: JobExecution) { - if (!jobExecution.isDone && !jobExecution.isPausing && jobExecution.isPaused) { + if (!jobExecution.isDone && jobExecution.isPaused) { val job = jobExecution.job if (job is Pauseable) { diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt index 606795234..5556b12c5 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt @@ -24,7 +24,7 @@ class JobExecution( @JvmField val cancellationToken = CancellationToken() inline val canContinue - get() = status == JobStatus.STARTED + get() = status == JobStatus.STARTED || status == JobStatus.PAUSED inline val isStopping get() = status == JobStatus.STOPPING @@ -32,9 +32,6 @@ class JobExecution( inline val isStopped get() = status == JobStatus.STOPPED - inline val isPausing - get() = status == JobStatus.PAUSING - inline val isPaused get() = status == JobStatus.PAUSED diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobStatus.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobStatus.kt index 306c90e7e..e680702fc 100644 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobStatus.kt +++ b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobStatus.kt @@ -5,7 +5,6 @@ enum class JobStatus { STARTED, STOPPING, STOPPED, - PAUSING, PAUSED, FAILED, COMPLETED, diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt index 285667774..82311f2cf 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt @@ -62,7 +62,7 @@ class CancellationToken private constructor(private val completable: Completable } override fun isCancelled(): Boolean { - return isDone + return completable != null && isDone } override fun isDone(): Boolean { From f02707839924db050c7855f6a68a1dac6781255d Mon Sep 17 00:00:00 2001 From: tiagohm Date: Mon, 12 Feb 2024 22:29:18 -0300 Subject: [PATCH 13/87] [api]: Add support to ASCOM Alpaca API --- api/build.gradle.kts | 1 + .../api/connection/ConnectionController.kt | 5 +- .../api/connection/ConnectionService.kt | 65 ++++--- .../nebulosa/api/connection/ConnectionType.kt | 6 + .../nebulosa/alpaca/api/AlpacaException.kt | 2 +- .../nebulosa/alpaca/api/AlpacaResponse.kt | 21 ++- .../nebulosa/alpaca/api/AlpacaService.kt | 6 +- .../nebulosa/alpaca/api/BoolResponse.kt | 11 ++ .../main/kotlin/nebulosa/alpaca/api/Camera.kt | 14 -- .../nebulosa/alpaca/api/CameraService.kt | 14 ++ .../nebulosa/alpaca/api/ConfiguredDevice.kt | 2 +- .../main/kotlin/nebulosa/alpaca/api/Device.kt | 10 -- .../nebulosa/alpaca/api/DeviceService.kt | 10 ++ .../kotlin/nebulosa/alpaca/api/DeviceType.kt | 17 ++ .../nebulosa/alpaca/api/ListResponse.kt | 11 ++ .../{Management.kt => ManagementService.kt} | 4 +- .../nebulosa/alpaca/api/NoneResponse.kt | 13 ++ .../kotlin/nebulosa/alpaca/api/Telescope.kt | 14 -- .../nebulosa/alpaca/api/TelescopeService.kt | 14 ++ .../src/test/kotlin/AlpacaServiceTest.kt | 21 +-- .../kotlin/AlpacaDiscoveryProtocolTest.kt | 24 +++ nebulosa-alpaca-indi/build.gradle.kts | 19 +++ .../alpaca/indi/client/AlpacaClient.kt | 126 ++++++++++++++ .../alpaca/indi/devices/ASCOMCamera.kt | 159 ++++++++++++++++++ .../alpaca/indi/devices/ASCOMDevice.kt | 20 +++ .../nebulosa/indi/client/DefaultINDIClient.kt | 112 ------------ .../kotlin/nebulosa/indi/client/INDIClient.kt | 103 ++++++++++-- .../client/device/DeviceProtocolHandler.kt | 4 +- .../indi/client/device/FilterWheelDevice.kt | 2 +- .../indi/client/device/FocuserDevice.kt | 2 +- .../nebulosa/indi/client/device/GPSDevice.kt | 2 +- .../{AbstractDevice.kt => INDIDevice.kt} | 5 +- .../indi/client/device/camera/CameraDevice.kt | 10 +- .../indi/client/device/mount/MountDevice.kt | 4 +- .../kotlin/nebulosa/indi/device/DeviceHub.kt | 40 +++++ settings.gradle.kts | 1 + 36 files changed, 658 insertions(+), 236 deletions(-) create mode 100644 api/src/main/kotlin/nebulosa/api/connection/ConnectionType.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/BoolResponse.kt delete mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Camera.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraService.kt delete mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Device.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DeviceService.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DeviceType.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ListResponse.kt rename nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/{Management.kt => ManagementService.kt} (56%) create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/NoneResponse.kt delete mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Telescope.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/TelescopeService.kt create mode 100644 nebulosa-alpaca-discovery-protocol/src/test/kotlin/AlpacaDiscoveryProtocolTest.kt create mode 100644 nebulosa-alpaca-indi/build.gradle.kts create mode 100644 nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt create mode 100644 nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMCamera.kt create mode 100644 nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt delete mode 100644 nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/DefaultINDIClient.kt rename nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/{AbstractDevice.kt => INDIDevice.kt} (98%) create mode 100644 nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceHub.kt diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 1a1b542c1..2c1d60817 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { implementation(project(":nebulosa-alignment")) implementation(project(":nebulosa-astap")) implementation(project(":nebulosa-astrometrynet")) + implementation(project(":nebulosa-alpaca-indi")) implementation(project(":nebulosa-batch-processing")) implementation(project(":nebulosa-common")) implementation(project(":nebulosa-guiding-phd2")) diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionController.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionController.kt index 51ea35ad2..f0c5f4d47 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionController.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionController.kt @@ -3,8 +3,10 @@ package nebulosa.api.connection import jakarta.validation.Valid import jakarta.validation.constraints.NotBlank import org.hibernate.validator.constraints.Range +import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.* +@Validated @RestController @RequestMapping("connection") class ConnectionController( @@ -15,8 +17,9 @@ class ConnectionController( fun connect( @RequestParam @Valid @NotBlank host: String, @RequestParam @Valid @Range(min = 1, max = 65535) port: Int, + @RequestParam(required = false, defaultValue = "INDI") type: ConnectionType, ) { - connectionService.connect(host, port) + connectionService.connect(host, port, type) } @DeleteMapping diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt index 4213b2c90..806a37eaa 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt @@ -1,8 +1,9 @@ package nebulosa.api.connection -import nebulosa.indi.client.DefaultINDIClient +import nebulosa.alpaca.indi.client.AlpacaClient import nebulosa.indi.client.INDIClient import nebulosa.indi.device.Device +import nebulosa.indi.device.DeviceHub import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.filterwheel.FilterWheel import nebulosa.indi.device.focuser.Focuser @@ -12,6 +13,7 @@ import nebulosa.indi.device.mount.Mount import nebulosa.indi.device.thermometer.Thermometer import nebulosa.log.error import nebulosa.log.loggerFor +import okhttp3.OkHttpClient import org.greenrobot.eventbus.EventBus import org.springframework.stereotype.Service import org.springframework.web.server.ServerErrorException @@ -21,25 +23,36 @@ import java.io.Closeable class ConnectionService( private val eventBus: EventBus, private val connectionEventHandler: ConnectionEventHandler, + private val httpClient: OkHttpClient, ) : Closeable { - @Volatile private var client: INDIClient? = null + @Volatile private var deviceHub: DeviceHub? = null fun connectionStatus(): Boolean { - return client != null + return deviceHub != null } @Synchronized - fun connect(host: String, port: Int) { + fun connect(host: String, port: Int, type: ConnectionType) { try { disconnect() - val client = DefaultINDIClient(host, port) - client.registerDeviceEventHandler(eventBus::post) - client.registerDeviceEventHandler(connectionEventHandler) - client.start() - - this.client = client + deviceHub = when (type) { + ConnectionType.INDI -> { + val client = INDIClient(host, port) + client.registerDeviceEventHandler(eventBus::post) + client.registerDeviceEventHandler(connectionEventHandler) + client.start() + client + } + else -> { + val client = AlpacaClient(host, port, httpClient) + client.registerDeviceEventHandler(eventBus::post) + client.registerDeviceEventHandler(connectionEventHandler) + client.discovery() + client + } + } } catch (e: Throwable) { LOG.error(e) @@ -49,8 +62,8 @@ class ConnectionService( @Synchronized fun disconnect() { - runCatching { client?.close() } - client = null + (deviceHub as? Closeable)?.close() + deviceHub = null } override fun close() { @@ -58,59 +71,59 @@ class ConnectionService( } fun cameras(): List { - return client?.cameras() ?: emptyList() + return deviceHub?.cameras() ?: emptyList() } fun mounts(): List { - return client?.mounts() ?: emptyList() + return deviceHub?.mounts() ?: emptyList() } fun focusers(): List { - return client?.focusers() ?: emptyList() + return deviceHub?.focusers() ?: emptyList() } fun wheels(): List { - return client?.wheels() ?: emptyList() + return deviceHub?.wheels() ?: emptyList() } fun gps(): List { - return client?.gps() ?: emptyList() + return deviceHub?.gps() ?: emptyList() } fun guideOutputs(): List { - return client?.guideOutputs() ?: emptyList() + return deviceHub?.guideOutputs() ?: emptyList() } fun thermometers(): List { - return client?.thermometers() ?: emptyList() + return deviceHub?.thermometers() ?: emptyList() } fun camera(name: String): Camera? { - return client?.camera(name) + return deviceHub?.camera(name) } fun mount(name: String): Mount? { - return client?.mount(name) + return deviceHub?.mount(name) } fun focuser(name: String): Focuser? { - return client?.focuser(name) + return deviceHub?.focuser(name) } fun wheel(name: String): FilterWheel? { - return client?.wheel(name) + return deviceHub?.wheel(name) } fun gps(name: String): GPS? { - return client?.gps(name) + return deviceHub?.gps(name) } fun guideOutput(name: String): GuideOutput? { - return client?.guideOutput(name) + return deviceHub?.guideOutput(name) } fun thermometer(name: String): Thermometer? { - return client?.thermometer(name) + return deviceHub?.thermometer(name) } fun device(name: String): Device? { diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionType.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionType.kt new file mode 100644 index 000000000..be788c578 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionType.kt @@ -0,0 +1,6 @@ +package nebulosa.api.connection + +enum class ConnectionType { + INDI, + ALPACA, +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaException.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaException.kt index 6bed44490..e4d543563 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaException.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaException.kt @@ -3,4 +3,4 @@ package nebulosa.alpaca.api import retrofit2.HttpException import retrofit2.Response -class AlpacaException(response: Response>) : HttpException(response) +open class AlpacaException(response: Response>) : HttpException(response) diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaResponse.kt index 2f96af9b0..f1d09f5ea 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaResponse.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaResponse.kt @@ -1,11 +1,14 @@ package nebulosa.alpaca.api -import com.fasterxml.jackson.annotation.JsonProperty - -data class AlpacaResponse( - @field:JsonProperty("ClientTransactionID") val clientTransactionID: Int = 0, - @field:JsonProperty("ServerTransactionID") val serverTransactionID: Int = 0, - @field:JsonProperty("ErrorNumber") val errorNumber: Int = 0, - @field:JsonProperty("ErrorMessage") val errorMessage: String = "", - @field:JsonProperty("Value") val value: T? = null, -) +sealed interface AlpacaResponse { + + val clientTransactionID: Int + + val serverTransactionID: Int + + val errorNumber: Int + + val errorMessage: String + + val value: T +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt index 2fc71fcea..cbc95700f 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt @@ -15,9 +15,9 @@ class AlpacaService( httpClient: OkHttpClient? = null, ) : RetrofitService(url, httpClient) { - val management by lazy { retrofit.create() } + val management by lazy { retrofit.create() } - val camera by lazy { retrofit.create() } + val camera by lazy { retrofit.create() } - val telescope by lazy { retrofit.create() } + val telescope by lazy { retrofit.create() } } diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/BoolResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/BoolResponse.kt new file mode 100644 index 000000000..798eb1cf6 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/BoolResponse.kt @@ -0,0 +1,11 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +data class BoolResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", + @field:JsonProperty("Value") override val value: Boolean = false, +) : AlpacaResponse diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Camera.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Camera.kt deleted file mode 100644 index 9b0503b29..000000000 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Camera.kt +++ /dev/null @@ -1,14 +0,0 @@ -package nebulosa.alpaca.api - -import retrofit2.Call -import retrofit2.http.* - -interface Camera : Device { - - @GET("camera/{deviceNumber}/connected") - override fun isConnected(@Path("deviceNumber") deviceNumber: Int): Call> - - @FormUrlEncoded - @PUT("camera/{deviceNumber}/connected") - override fun connect(@Path("deviceNumber") deviceNumber: Int, @Field("Connected") connected: Boolean): Call> -} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraService.kt new file mode 100644 index 000000000..ddd98bd84 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraService.kt @@ -0,0 +1,14 @@ +package nebulosa.alpaca.api + +import retrofit2.Call +import retrofit2.http.* + +interface CameraService : DeviceService { + + @GET("camera/{id}/connected") + override fun isConnected(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("camera/{id}/connected") + override fun connect(@Path("id") id: Int, @Field("Connected") connected: Boolean): Call +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ConfiguredDevice.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ConfiguredDevice.kt index e02c06f46..d1c85fa30 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ConfiguredDevice.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ConfiguredDevice.kt @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty data class ConfiguredDevice( @JsonProperty("DeviceName") val name: String = "", - @JsonProperty("DeviceType") val type: String = "", + @JsonProperty("DeviceType") val type: DeviceType = DeviceType.CAMERA, @JsonProperty("DeviceNumber") val number: Int = 0, @JsonProperty("UniqueID") val uid: String = "", ) diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Device.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Device.kt deleted file mode 100644 index 5e8156c1c..000000000 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Device.kt +++ /dev/null @@ -1,10 +0,0 @@ -package nebulosa.alpaca.api - -import retrofit2.Call - -sealed interface Device { - - fun isConnected(deviceNumber: Int): Call> - - fun connect(deviceNumber: Int, connected: Boolean): Call> -} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DeviceService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DeviceService.kt new file mode 100644 index 000000000..a627c535b --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DeviceService.kt @@ -0,0 +1,10 @@ +package nebulosa.alpaca.api + +import retrofit2.Call + +sealed interface DeviceService { + + fun isConnected(id: Int): Call + + fun connect(id: Int, connected: Boolean): Call +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DeviceType.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DeviceType.kt new file mode 100644 index 000000000..3c5afee5a --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DeviceType.kt @@ -0,0 +1,17 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonValue + +enum class DeviceType(@JsonValue val type: String) { + CAMERA("Camera"), + TELESCOPE("Telescope"), + FOCUSER("Focuser"), + FILTER_WHEEL("FilterWheel"), + ROTATOR("Rotator"), + DOME("Dome"), + SWITCH("Switch"), + COVER_CALIBRATOR("CoverCalibrator"), + OBSERVING_CONDITIONS("ObservingConditions"), + SAFETY_MONITOR("SafetyMonitor"), + VIDEO("Video"), +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ListResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ListResponse.kt new file mode 100644 index 000000000..194401b36 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ListResponse.kt @@ -0,0 +1,11 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +data class ListResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", + @field:JsonProperty("Value") override val value: List = emptyList(), +) : AlpacaResponse> diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Management.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ManagementService.kt similarity index 56% rename from nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Management.kt rename to nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ManagementService.kt index 1f6e64ab1..d95e34e08 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Management.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ManagementService.kt @@ -3,8 +3,8 @@ package nebulosa.alpaca.api import retrofit2.Call import retrofit2.http.GET -interface Management { +interface ManagementService { @GET("management/v1/configureddevices") - fun configuredDevices(): Call>> + fun configuredDevices(): Call> } diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/NoneResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/NoneResponse.kt new file mode 100644 index 000000000..f8e3a9a5a --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/NoneResponse.kt @@ -0,0 +1,13 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +data class NoneResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", +) : AlpacaResponse { + + @JsonProperty("Value") override val value = Unit +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Telescope.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Telescope.kt deleted file mode 100644 index c066cc173..000000000 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/Telescope.kt +++ /dev/null @@ -1,14 +0,0 @@ -package nebulosa.alpaca.api - -import retrofit2.Call -import retrofit2.http.* - -interface Telescope : Device { - - @GET("telescope/{deviceNumber}/connected") - override fun isConnected(@Path("deviceNumber") deviceNumber: Int): Call> - - @FormUrlEncoded - @PUT("telescope/{deviceNumber}/connected") - override fun connect(@Path("deviceNumber") deviceNumber: Int, @Field("Connected") connected: Boolean): Call> -} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/TelescopeService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/TelescopeService.kt new file mode 100644 index 000000000..906ba6b6b --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/TelescopeService.kt @@ -0,0 +1,14 @@ +package nebulosa.alpaca.api + +import retrofit2.Call +import retrofit2.http.* + +interface TelescopeService : DeviceService { + + @GET("telescope/{id}/connected") + override fun isConnected(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("telescope/{id}/connected") + override fun connect(@Path("id") id: Int, @Field("Connected") connected: Boolean): Call +} diff --git a/nebulosa-alpaca-api/src/test/kotlin/AlpacaServiceTest.kt b/nebulosa-alpaca-api/src/test/kotlin/AlpacaServiceTest.kt index 3c05650e1..462085682 100644 --- a/nebulosa-alpaca-api/src/test/kotlin/AlpacaServiceTest.kt +++ b/nebulosa-alpaca-api/src/test/kotlin/AlpacaServiceTest.kt @@ -1,20 +1,21 @@ +import io.kotest.core.annotation.EnabledIf import io.kotest.core.spec.style.StringSpec -import io.kotest.matchers.booleans.shouldBeTrue -import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull import nebulosa.alpaca.api.AlpacaService +import nebulosa.test.NonGitHubOnlyCondition +@EnabledIf(NonGitHubOnlyCondition::class) class AlpacaServiceTest : StringSpec() { init { - val client = AlpacaService("https://virtserver.swaggerhub.com/ASCOMInitiative/api/v1/") + val client = AlpacaService("http://localhost:11111/") - "camera" { - client.camera.isConnected(0).execute().body()!!.value!!.shouldBeTrue() - client.camera.connect(0, true).execute().body()!!.value.shouldBeNull() - } - "telescope" { - client.telescope.isConnected(0).execute().body()!!.value!!.shouldBeTrue() - client.telescope.connect(0, true).execute().body()!!.value.shouldBeNull() + "management" { + val body = client.management.configuredDevices().execute().body().shouldNotBeNull() + + for (device in body.value) { + println(device) + } } } } diff --git a/nebulosa-alpaca-discovery-protocol/src/test/kotlin/AlpacaDiscoveryProtocolTest.kt b/nebulosa-alpaca-discovery-protocol/src/test/kotlin/AlpacaDiscoveryProtocolTest.kt new file mode 100644 index 000000000..515dc40f5 --- /dev/null +++ b/nebulosa-alpaca-discovery-protocol/src/test/kotlin/AlpacaDiscoveryProtocolTest.kt @@ -0,0 +1,24 @@ +import io.kotest.core.annotation.EnabledIf +import io.kotest.core.spec.style.StringSpec +import nebulosa.alpaca.discovery.AlpacaDiscoveryProtocol +import nebulosa.alpaca.discovery.DiscoveryListener +import nebulosa.test.NonGitHubOnlyCondition +import java.net.InetAddress +import kotlin.concurrent.thread + +@EnabledIf(NonGitHubOnlyCondition::class) +class AlpacaDiscoveryProtocolTest : StringSpec(), DiscoveryListener { + + init { + "discovery" { + val discoverer = AlpacaDiscoveryProtocol() + discoverer.registerDiscoveryListener(this@AlpacaDiscoveryProtocolTest) + thread { Thread.sleep(10000); discoverer.close() } + discoverer.run() + } + } + + override fun onServerFound(address: InetAddress, port: Int) { + println("$address:$port") + } +} diff --git a/nebulosa-alpaca-indi/build.gradle.kts b/nebulosa-alpaca-indi/build.gradle.kts new file mode 100644 index 000000000..c2649cd18 --- /dev/null +++ b/nebulosa-alpaca-indi/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + kotlin("jvm") + id("maven-publish") +} + +dependencies { + api(project(":nebulosa-alpaca-api")) + api(project(":nebulosa-indi-device")) + implementation(project(":nebulosa-log")) + testImplementation(project(":nebulosa-test")) +} + +publishing { + publications { + create("pluginMaven") { + from(components["java"]) + } + } +} diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt new file mode 100644 index 000000000..0ccd89172 --- /dev/null +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt @@ -0,0 +1,126 @@ +package nebulosa.alpaca.indi.client + +import nebulosa.alpaca.api.AlpacaService +import nebulosa.alpaca.api.DeviceType +import nebulosa.alpaca.indi.devices.ASCOMCamera +import nebulosa.indi.device.DeviceEvent +import nebulosa.indi.device.DeviceEventHandler +import nebulosa.indi.device.DeviceHub +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.camera.CameraAttached +import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.indi.device.focuser.Focuser +import nebulosa.indi.device.gps.GPS +import nebulosa.indi.device.guide.GuideOutput +import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.thermometer.Thermometer +import nebulosa.log.loggerFor +import okhttp3.OkHttpClient + +class AlpacaClient( + host: String, + port: Int, + httpClient: OkHttpClient? = null, +) : DeviceHub { + + private val service = AlpacaService("http://$host:$port/", httpClient) + private val handlers = LinkedHashSet() + private val cameras = HashMap() + + fun registerDeviceEventHandler(handler: DeviceEventHandler) { + handlers.add(handler) + } + + fun unregisterDeviceEventHandler(handler: DeviceEventHandler) { + handlers.remove(handler) + } + + fun fireOnEventReceived(event: DeviceEvent<*>) { + handlers.forEach { it.onEventReceived(event) } + } + + override fun cameras(): List { + return cameras.values.toList() + } + + override fun camera(name: String): Camera? { + return cameras[name] ?: cameras.values.find { it.name == name } + } + + override fun mounts(): List { + return emptyList() + } + + override fun mount(name: String): Mount? { + return null + } + + override fun focusers(): List { + return emptyList() + } + + override fun focuser(name: String): Focuser? { + return null + } + + override fun wheels(): List { + return emptyList() + } + + override fun wheel(name: String): FilterWheel? { + return null + } + + override fun gps(): List { + return emptyList() + } + + override fun gps(name: String): GPS? { + return null + } + + override fun guideOutputs(): List { + return emptyList() + } + + override fun guideOutput(name: String): GuideOutput? { + return null + } + + override fun thermometers(): List { + return emptyList() + } + + override fun thermometer(name: String): Thermometer? { + return null + } + + @Synchronized + fun discovery() { + val response = service.management.configuredDevices().execute() + + if (response.isSuccessful) { + val body = response.body() ?: return + + for (device in body.value) { + if (device.type == DeviceType.CAMERA) { + if (device.uid in cameras) continue + + with(ASCOMCamera(device, service.camera)) { + cameras[device.uid] = this + fireOnEventReceived(CameraAttached(this)) + } + } + } + } else { + val body = response.errorBody() + LOG.warn("unsuccessful response. code={}, body={}", response.code(), body?.string()) + body?.close() + } + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } +} diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMCamera.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMCamera.kt new file mode 100644 index 000000000..d013241f8 --- /dev/null +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMCamera.kt @@ -0,0 +1,159 @@ +package nebulosa.alpaca.indi.devices + +import nebulosa.alpaca.api.CameraService +import nebulosa.alpaca.api.ConfiguredDevice +import nebulosa.imaging.algorithms.transformation.CfaPattern +import nebulosa.indi.device.Device +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.camera.FrameType +import nebulosa.indi.protocol.INDIProtocol +import nebulosa.indi.protocol.PropertyState +import java.time.Duration + +data class ASCOMCamera( + override val device: ConfiguredDevice, + override val service: CameraService, +) : ASCOMDevice(), Camera { + + override var exposuring = false + override var hasCoolerControl = false + override var coolerPower = 0.0 + override var cooler = false + override var hasDewHeater = false + override var dewHeater = false + override var frameFormats = emptyList() + override var canAbort = false + override var cfaOffsetX = 0 + override var cfaOffsetY = 0 + override var cfaType = CfaPattern.RGGB + override var exposureMin: Duration = Duration.ZERO + override var exposureMax: Duration = Duration.ZERO + override var exposureState = PropertyState.IDLE + override var exposureTime: Duration = Duration.ZERO + override var hasCooler = false + override var canSetTemperature = false + override var canSubFrame = false + override var x = 0 + override var minX = 0 + override var maxX = 0 + override var y = 0 + override var minY = 0 + override var maxY = 0 + override var width = 0 + override var minWidth = 0 + override var maxWidth = 0 + override var height = 0 + override var minHeight = 0 + override var maxHeight = 0 + override var canBin = false + override var maxBinX = 1 + override var maxBinY = 1 + override var binX = 1 + override var binY = 1 + override var gain = 0 + override var gainMin = 0 + override var gainMax = 0 + override var offset = 0 + override var offsetMin = 0 + override var offsetMax = 0 + override var hasGuiderHead = false // TODO: ASCOM has guider head? + override var pixelSizeX = 0.0 + override var pixelSizeY = 0.0 + + override var hasThermometer = false + override var temperature = 0.0 + + override var canPulseGuide = false + override var pulseGuiding = false + + override fun cooler(enabled: Boolean) { + TODO("Not yet implemented") + } + + override fun dewHeater(enabled: Boolean) { + TODO("Not yet implemented") + } + + override fun temperature(value: Double) { + TODO("Not yet implemented") + } + + override fun frameFormat(format: String?) { + TODO("Not yet implemented") + } + + override fun frameType(type: FrameType) { + TODO("Not yet implemented") + } + + override fun frame(x: Int, y: Int, width: Int, height: Int) { + TODO("Not yet implemented") + } + + override fun bin(x: Int, y: Int) { + TODO("Not yet implemented") + } + + override fun gain(value: Int) { + TODO("Not yet implemented") + } + + override fun offset(value: Int) { + TODO("Not yet implemented") + } + + override fun startCapture(exposureTime: Duration) { + TODO("Not yet implemented") + } + + override fun abortCapture() { + TODO("Not yet implemented") + } + + override fun guideNorth(duration: Duration) { + TODO("Not yet implemented") + } + + override fun guideSouth(duration: Duration) { + TODO("Not yet implemented") + } + + override fun guideEast(duration: Duration) { + TODO("Not yet implemented") + } + + override fun guideWest(duration: Duration) { + TODO("Not yet implemented") + } + + override val connected: Boolean + get() = TODO("Not yet implemented") + + override fun connect() { + TODO("Not yet implemented") + } + + override fun disconnect() { + TODO("Not yet implemented") + } + + override fun sendMessageToServer(message: INDIProtocol) { + TODO("Not yet implemented") + } + + override fun snoop(devices: Iterable) { + TODO("Not yet implemented") + } + + override fun handleMessage(message: INDIProtocol) { + TODO("Not yet implemented") + } + + override fun close() { + TODO("Not yet implemented") + } + + override fun refresh() { + TODO("Not yet implemented") + } +} diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt new file mode 100644 index 000000000..0ee2ca8ce --- /dev/null +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt @@ -0,0 +1,20 @@ +package nebulosa.alpaca.indi.devices + +import nebulosa.alpaca.api.ConfiguredDevice +import nebulosa.alpaca.api.DeviceService +import nebulosa.indi.device.Device +import nebulosa.indi.device.PropertyVector + +abstract class ASCOMDevice : Device { + + protected abstract val device: ConfiguredDevice + protected abstract val service: DeviceService + + override val name + get() = device.name + + override val properties = emptyMap>() + override val messages = emptyList() + + abstract fun refresh() +} diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/DefaultINDIClient.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/DefaultINDIClient.kt deleted file mode 100644 index 165136812..000000000 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/DefaultINDIClient.kt +++ /dev/null @@ -1,112 +0,0 @@ -package nebulosa.indi.client - -import nebulosa.indi.client.connection.INDIProccessConnection -import nebulosa.indi.client.connection.INDISocketConnection -import nebulosa.indi.client.device.DeviceProtocolHandler -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.filterwheel.FilterWheel -import nebulosa.indi.device.focuser.Focuser -import nebulosa.indi.device.gps.GPS -import nebulosa.indi.device.guide.GuideOutput -import nebulosa.indi.device.mount.Mount -import nebulosa.indi.device.thermometer.Thermometer -import nebulosa.indi.protocol.GetProperties -import nebulosa.indi.protocol.INDIProtocol -import nebulosa.indi.protocol.io.INDIConnection -import nebulosa.log.debug -import nebulosa.log.loggerFor - -open class DefaultINDIClient(override val connection: INDIConnection) : DeviceProtocolHandler(), INDIClient { - - constructor( - host: String, - port: Int = INDIProtocol.DEFAULT_PORT, - ) : this(INDISocketConnection(host, port)) - - constructor( - process: Process, - ) : this(INDIProccessConnection(process)) - - override val isClosed - get() = !connection.isOpen - - override val input - get() = connection.input - - override fun start() { - super.start() - sendMessageToServer(GetProperties()) - } - - override fun sendMessageToServer(message: INDIProtocol) { - LOG.debug { "sending message: $message" } - connection.writeINDIProtocol(message) - } - - override fun cameras(): List { - return cameras.values.toList() - } - - override fun camera(name: String): Camera? { - return cameras[name] - } - - override fun mounts(): List { - return mounts.values.toList() - } - - override fun mount(name: String): Mount? { - return mounts[name] - } - - override fun focusers(): List { - return focusers.values.toList() - } - - override fun focuser(name: String): Focuser? { - return focusers[name] - } - - override fun wheels(): List { - return wheels.values.toList() - } - - override fun wheel(name: String): FilterWheel? { - return wheels[name] - } - - override fun gps(): List { - return gps.values.toList() - } - - override fun gps(name: String): GPS? { - return gps[name] - } - - override fun guideOutputs(): List { - return guideOutputs.values.toList() - } - - override fun guideOutput(name: String): GuideOutput? { - return guideOutputs[name] - } - - override fun thermometers(): List { - return thermometers.values.toList() - } - - override fun thermometer(name: String): Thermometer? { - return thermometers[name] - } - - override fun close() { - super.close() - - connection.close() - } - - companion object { - - @JvmStatic private val LOG = loggerFor() - } -} diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt index ddd342e4e..ec47e0f1e 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt @@ -1,5 +1,9 @@ package nebulosa.indi.client +import nebulosa.indi.client.connection.INDIProccessConnection +import nebulosa.indi.client.connection.INDISocketConnection +import nebulosa.indi.client.device.DeviceProtocolHandler +import nebulosa.indi.device.DeviceHub import nebulosa.indi.device.MessageSender import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.filterwheel.FilterWheel @@ -8,40 +12,103 @@ import nebulosa.indi.device.gps.GPS import nebulosa.indi.device.guide.GuideOutput import nebulosa.indi.device.mount.Mount import nebulosa.indi.device.thermometer.Thermometer +import nebulosa.indi.protocol.GetProperties +import nebulosa.indi.protocol.INDIProtocol import nebulosa.indi.protocol.io.INDIConnection -import nebulosa.indi.protocol.parser.INDIProtocolParser +import nebulosa.log.debug +import nebulosa.log.loggerFor -interface INDIClient : INDIProtocolParser, MessageSender { +class INDIClient(private val connection: INDIConnection) : DeviceProtocolHandler(), MessageSender, DeviceHub { - val connection: INDIConnection + constructor( + host: String, + port: Int = INDIProtocol.DEFAULT_PORT, + ) : this(INDISocketConnection(host, port)) - fun start() + constructor( + process: Process, + ) : this(INDIProccessConnection(process)) - fun cameras(): List + override val isClosed + get() = !connection.isOpen - fun camera(name: String): Camera? + override val input + get() = connection.input - fun mounts(): List + override fun start() { + super.start() + sendMessageToServer(GetProperties()) + } - fun mount(name: String): Mount? + override fun sendMessageToServer(message: INDIProtocol) { + LOG.debug { "sending message: $message" } + connection.writeINDIProtocol(message) + } - fun focusers(): List + override fun cameras(): List { + return cameras.values.toList() + } - fun focuser(name: String): Focuser? + override fun camera(name: String): Camera? { + return cameras[name] + } - fun wheels(): List + override fun mounts(): List { + return mounts.values.toList() + } - fun wheel(name: String): FilterWheel? + override fun mount(name: String): Mount? { + return mounts[name] + } - fun gps(): List + override fun focusers(): List { + return focusers.values.toList() + } - fun gps(name: String): GPS? + override fun focuser(name: String): Focuser? { + return focusers[name] + } - fun guideOutputs(): List + override fun wheels(): List { + return wheels.values.toList() + } - fun guideOutput(name: String): GuideOutput? + override fun wheel(name: String): FilterWheel? { + return wheels[name] + } - fun thermometers(): List + override fun gps(): List { + return gps.values.toList() + } - fun thermometer(name: String): Thermometer? + override fun gps(name: String): GPS? { + return gps[name] + } + + override fun guideOutputs(): List { + return guideOutputs.values.toList() + } + + override fun guideOutput(name: String): GuideOutput? { + return guideOutputs[name] + } + + override fun thermometers(): List { + return thermometers.values.toList() + } + + override fun thermometer(name: String): Thermometer? { + return thermometers[name] + } + + override fun close() { + super.close() + + connection.close() + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/DeviceProtocolHandler.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/DeviceProtocolHandler.kt index b0b683e50..991c83e0a 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/DeviceProtocolHandler.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/DeviceProtocolHandler.kt @@ -1,6 +1,6 @@ package nebulosa.indi.client.device -import nebulosa.indi.client.device.AbstractDevice.Companion.create +import nebulosa.indi.client.device.INDIDevice.Companion.create import nebulosa.indi.client.device.camera.AsiCamera import nebulosa.indi.client.device.camera.CameraDevice import nebulosa.indi.client.device.camera.SVBonyCamera @@ -52,7 +52,7 @@ abstract class DeviceProtocolHandler : MessageSender, INDIProtocolParser { private val notRegisteredDevices = HashSet() @Volatile private var protocolReader: INDIProtocolReader? = null private val messageQueueCounter = HashMap(2048) - private val handlers = ArrayList() + private val handlers = LinkedHashSet() val isRunning get() = protocolReader != null diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt index 23455c84d..7e740746d 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt @@ -9,7 +9,7 @@ import nebulosa.indi.protocol.PropertyState internal open class FilterWheelDevice( handler: DeviceProtocolHandler, name: String, -) : AbstractDevice(handler, name), FilterWheel { +) : INDIDevice(handler, name), FilterWheel { override var count = 0 override var position = -1 diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt index 0fe613cb2..a8da9ff08 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt @@ -9,7 +9,7 @@ import nebulosa.indi.protocol.* internal open class FocuserDevice( handler: DeviceProtocolHandler, name: String, -) : AbstractDevice(handler, name), Focuser { +) : INDIDevice(handler, name), Focuser { override var moving = false override var position = 0 diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt index 4386bff0f..6285a46ff 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt @@ -14,7 +14,7 @@ import java.time.ZoneOffset internal class GPSDevice( handler: DeviceProtocolHandler, name: String, -) : AbstractDevice(handler, name), GPS { +) : INDIDevice(handler, name), GPS { override val hasGPS = true override var longitude = 0.0 diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/AbstractDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt similarity index 98% rename from nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/AbstractDevice.kt rename to nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt index 7112bf2bb..0853b2c71 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/AbstractDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt @@ -13,13 +13,12 @@ import nebulosa.indi.protocol.Vector import nebulosa.log.loggerFor import java.util.* -internal abstract class AbstractDevice( +internal abstract class INDIDevice( @JvmField internal val handler: DeviceProtocolHandler, override val name: String, ) : Device { override val properties = linkedMapOf>() - override val messages = LinkedList() override var connected = false @@ -209,7 +208,7 @@ internal abstract class AbstractDevice( companion object { - @JvmStatic private val LOG = loggerFor() + @JvmStatic private val LOG = loggerFor() @JvmStatic fun Class.create(handler: DeviceProtocolHandler, name: String): T { diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt index 5d96adf67..90775d176 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt @@ -1,7 +1,7 @@ package nebulosa.indi.client.device.camera import nebulosa.imaging.algorithms.transformation.CfaPattern -import nebulosa.indi.client.device.AbstractDevice +import nebulosa.indi.client.device.INDIDevice import nebulosa.indi.client.device.DeviceProtocolHandler import nebulosa.indi.device.camera.* import nebulosa.indi.device.guide.GuideOutputPulsingChanged @@ -13,7 +13,7 @@ import java.time.Duration internal open class CameraDevice( handler: DeviceProtocolHandler, name: String, -) : AbstractDevice(handler, name), Camera { +) : INDIDevice(handler, name), Camera { override var exposuring = false override var hasCoolerControl = false @@ -26,10 +26,10 @@ internal open class CameraDevice( override var cfaOffsetX = 0 override var cfaOffsetY = 0 override var cfaType = CfaPattern.RGGB - override var exposureMin = Duration.ZERO - override var exposureMax = Duration.ZERO + override var exposureMin: Duration = Duration.ZERO + override var exposureMax: Duration = Duration.ZERO override var exposureState = PropertyState.IDLE - override var exposureTime = Duration.ZERO + override var exposureTime: Duration = Duration.ZERO override var hasCooler = false override var canSetTemperature = false override var canSubFrame = false diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt index 9d1deb9fc..c5021d133 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt @@ -1,6 +1,6 @@ package nebulosa.indi.client.device.mount -import nebulosa.indi.client.device.AbstractDevice +import nebulosa.indi.client.device.INDIDevice import nebulosa.indi.client.device.DeviceProtocolHandler import nebulosa.indi.device.firstOnSwitch import nebulosa.indi.device.firstOnSwitchOrNull @@ -18,7 +18,7 @@ import java.time.ZoneOffset internal open class MountDevice( handler: DeviceProtocolHandler, name: String, -) : AbstractDevice(handler, name), Mount { +) : INDIDevice(handler, name), Mount { override var slewing = false override var tracking = false diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceHub.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceHub.kt new file mode 100644 index 000000000..a9ab84e87 --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceHub.kt @@ -0,0 +1,40 @@ +package nebulosa.indi.device + +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.indi.device.focuser.Focuser +import nebulosa.indi.device.gps.GPS +import nebulosa.indi.device.guide.GuideOutput +import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.thermometer.Thermometer + +interface DeviceHub { + + fun cameras(): List + + fun camera(name: String): Camera? + + fun mounts(): List + + fun mount(name: String): Mount? + + fun focusers(): List + + fun focuser(name: String): Focuser? + + fun wheels(): List + + fun wheel(name: String): FilterWheel? + + fun gps(): List + + fun gps(name: String): GPS? + + fun guideOutputs(): List + + fun guideOutput(name: String): GuideOutput? + + fun thermometers(): List + + fun thermometer(name: String): Thermometer? +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d26534d3e..2b677f50a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,6 +47,7 @@ include(":nebulosa-adql") include(":nebulosa-alignment") include(":nebulosa-alpaca-api") include(":nebulosa-alpaca-discovery-protocol") +include(":nebulosa-alpaca-indi") include(":nebulosa-astap") include(":nebulosa-astrometrynet") include(":nebulosa-astrometrynet-jna") From 9fe9ca3361b5c6809d3c1d565d6cfe736159de77 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Tue, 13 Feb 2024 00:11:39 -0300 Subject: [PATCH 14/87] [api][desktop]: Add support to ASCOM Alpaca API --- .../src/app/alignment/alignment.component.ts | 8 ++-- desktop/src/app/home/home.component.html | 13 +++--- desktop/src/app/home/home.component.scss | 33 +++++++++----- desktop/src/app/home/home.component.ts | 44 ++++++++++++------- desktop/src/app/image/image.component.ts | 2 +- .../src/app/settings/settings.component.ts | 2 +- desktop/src/shared/services/api.service.ts | 5 ++- .../src/shared/services/preference.service.ts | 7 +-- desktop/src/shared/types/home.types.ts | 8 +++- .../alpaca/indi/client/AlpacaClient.kt | 14 +++--- .../alpaca/indi/devices/ASCOMCamera.kt | 23 ++++++++-- 11 files changed, 105 insertions(+), 54 deletions(-) diff --git a/desktop/src/app/alignment/alignment.component.ts b/desktop/src/app/alignment/alignment.component.ts index 9c19fd9b6..36d8034a3 100644 --- a/desktop/src/app/alignment/alignment.component.ts +++ b/desktop/src/app/alignment/alignment.component.ts @@ -8,7 +8,7 @@ import { Angle } from '../../shared/types/atlas.types' import { Camera, EMPTY_CAMERA, EMPTY_CAMERA_START_CAPTURE, ExposureTimeUnit } 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 { DEFAULT_SOLVER_TYPES, EMPTY_PLATE_SOLVER_OPTIONS, PlateSolverType } from '../../shared/types/settings.types' +import { DEFAULT_SOLVER_TYPES, EMPTY_PLATE_SOLVER_OPTIONS } from '../../shared/types/settings.types' import { AppComponent } from '../app.component' import { CameraComponent } from '../camera/camera.component' @@ -48,7 +48,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { stepDistance: 10, } - readonly plateSolverTypes: PlateSolverType[] = Object.assign([], DEFAULT_SOLVER_TYPES) + readonly plateSolverTypes = Array.from(DEFAULT_SOLVER_TYPES) tppaAzimuthError: Angle = `00°00'00"` tppaAzimuthErrorDirection = '' tppaAltitudeError: Angle = `00°00'00"` @@ -334,7 +334,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } private loadPreference() { - const preference = this.preference.alignmentPreference().get() + const preference = this.preference.alignmentPreference.get() this.tppaRequest.startFromCurrentPosition = preference.tppaStartFromCurrentPosition this.tppaRequest.eastDirection = preference.tppaEastDirection @@ -373,6 +373,6 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { darvHemisphere: this.darvHemisphere, } - this.preference.alignmentPreference().set(preference) + this.preference.alignmentPreference.set(preference) } } \ No newline at end of file diff --git a/desktop/src/app/home/home.component.html b/desktop/src/app/home/home.component.html index 91994d46e..17a313da0 100644 --- a/desktop/src/app/home/home.component.html +++ b/desktop/src/app/home/home.component.html @@ -1,6 +1,6 @@
-
+
- {{ item.host }}:{{ item.port }} + {{ item.host }}:{{ item.port }}
@@ -23,17 +23,18 @@
- +
-
- +
+ - +
diff --git a/desktop/src/app/home/home.component.scss b/desktop/src/app/home/home.component.scss index ab73ace58..ef0bd1a18 100644 --- a/desktop/src/app/home/home.component.scss +++ b/desktop/src/app/home/home.component.scss @@ -1,19 +1,28 @@ -p-button ::ng-deep { - display: contents; +:host { + p-splitbutton ::ng-deep { + .p-splitbutton-menubutton { + width: 0rem; + padding: 4px; + } + } - &.open { - min-height: 56px; - max-height: 56px; - display: flex; + p-button ::ng-deep { + display: contents; - img { - height: 32px; + &.open { + min-height: 56px; + max-height: 56px; + display: flex; + + img { + height: 32px; + } } - } - &.p-disabled { - img { - filter: grayscale(1); + &.p-disabled { + img { + filter: grayscale(1); + } } } } diff --git a/desktop/src/app/home/home.component.ts b/desktop/src/app/home/home.component.ts index a17693914..6ecda8d4d 100644 --- a/desktop/src/app/home/home.component.ts +++ b/desktop/src/app/home/home.component.ts @@ -7,11 +7,11 @@ import { DialogMenuComponent } from '../../shared/components/dialog-menu/dialog- import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' import { ElectronService } from '../../shared/services/electron.service' -import { LocalStorageService } from '../../shared/services/local-storage.service' +import { PreferenceService } from '../../shared/services/preference.service' import { Camera } from '../../shared/types/camera.types' import { Device } from '../../shared/types/device.types' import { Focuser } from '../../shared/types/focuser.types' -import { ConnectionDetails, EMPTY_CONNECTION_DETAILS, HomeWindowType } from '../../shared/types/home.types' +import { ConnectionDetails, HomeWindowType } from '../../shared/types/home.types' import { Mount } from '../../shared/types/mount.types' import { FilterWheel } from '../../shared/types/wheel.types' import { compareDevice } from '../../shared/utils/comparators' @@ -24,9 +24,6 @@ type MappedDevice = { 'WHEEL': FilterWheel } -export const IMAGE_DIR_KEY = 'home.image.directory' -export const LAST_CONNECTED_HOSTS_KEY = 'home.lastConnectedHosts' - @Component({ selector: 'app-home', templateUrl: './home.component.html', @@ -44,6 +41,21 @@ export class HomeComponent implements AfterContentInit, OnDestroy { lastConnectedHosts: ConnectionDetails[] = [] connection: ConnectionDetails + readonly connectionTypeModel: MenuItem[] = [ + { + label: 'INDI', + command: () => { + this.connection.type = 'INDI' + }, + }, + { + label: 'ASCOM Alpaca', + command: () => { + this.connection.type = 'ALPACA' + }, + } + ] + cameras: Camera[] = [] mounts: Mount[] = [] focusers: Focuser[] = [] @@ -137,7 +149,7 @@ export class HomeComponent implements AfterContentInit, OnDestroy { private browserWindow: BrowserWindowService, private api: ApiService, private message: MessageService, - private storage: LocalStorageService, + private preference: PreferenceService, private ngZone: NgZone, ) { app.title = 'Nebulosa' @@ -182,8 +194,9 @@ export class HomeComponent implements AfterContentInit, OnDestroy { }, ) - this.lastConnectedHosts = storage.get(LAST_CONNECTED_HOSTS_KEY, []) - this.connection = Object.assign({}, this.lastConnectedHosts[0] ?? EMPTY_CONNECTION_DETAILS) + this.lastConnectedHosts = this.preference.lastConnectedHosts.get() + this.connection = Object.assign({}, this.lastConnectedHosts[0]) + this.connection.type ??= 'INDI' } async ngAfterContentInit() { @@ -212,7 +225,7 @@ export class HomeComponent implements AfterContentInit, OnDestroy { if (index >= 0) { this.lastConnectedHosts.splice(index, 1) - this.storage.set(LAST_CONNECTED_HOSTS_KEY, this.lastConnectedHosts) + this.preference.lastConnectedHosts.set(this.lastConnectedHosts) } event.stopImmediatePropagation() @@ -223,12 +236,13 @@ export class HomeComponent implements AfterContentInit, OnDestroy { if (this.connected) { await this.api.disconnect() } else { - let { host, port } = this.connection + let { host, port, type } = this.connection host ||= 'localhost' port ||= 7624 + type || 'INDI' - await this.api.connect(host, port) + await this.api.connect(host, port, type) const index = this.lastConnectedHosts.findIndex(e => e.host === host && e.port === port) @@ -236,10 +250,10 @@ export class HomeComponent implements AfterContentInit, OnDestroy { this.lastConnectedHosts.splice(index, 1) } - this.lastConnectedHosts.splice(0, 0, Object.assign({}, this.connection)) + this.lastConnectedHosts.splice(0, 0, this.connection) this.lastConnectedHosts[0].connectedAt = Date.now() - this.storage.set(LAST_CONNECTED_HOSTS_KEY, this.lastConnectedHosts) + this.preference.lastConnectedHosts.set(this.lastConnectedHosts) } } catch (e) { console.error(e) @@ -298,11 +312,11 @@ export class HomeComponent implements AfterContentInit, OnDestroy { private async openImage(force: boolean = false) { if (force || this.cameras.length === 0) { - const defaultPath = this.storage.get(IMAGE_DIR_KEY, '') + const defaultPath = this.preference.homeImageDefaultDirectory.get() const filePath = await this.electron.openFits({ defaultPath }) if (filePath) { - this.storage.set(IMAGE_DIR_KEY, path.dirname(filePath)) + this.preference.homeImageDefaultDirectory.set(path.dirname(filePath)) this.browserWindow.openImage({ path: filePath, source: 'PATH' }) } } else { diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 6b116ecd7..4234076e6 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -77,7 +77,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { solverCenterDEC = '' solverRadius = 4 readonly solvedData = Object.assign({}, EMPTY_IMAGE_SOLVED) - readonly solverTypes: PlateSolverType[] = Object.assign([], DEFAULT_SOLVER_TYPES) + readonly solverTypes = Array.from(DEFAULT_SOLVER_TYPES) solverType = this.solverTypes[0] crossHair = false diff --git a/desktop/src/app/settings/settings.component.ts b/desktop/src/app/settings/settings.component.ts index f2b141903..29a8707a4 100644 --- a/desktop/src/app/settings/settings.component.ts +++ b/desktop/src/app/settings/settings.component.ts @@ -22,7 +22,7 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { locations: Location[] = [] location = Object.assign({}, EMPTY_LOCATION) - readonly plateSolverTypes: PlateSolverType[] = Object.assign([], DEFAULT_SOLVER_TYPES) + readonly plateSolverTypes = Array.from(DEFAULT_SOLVER_TYPES) plateSolverType = this.plateSolverTypes[0] readonly plateSolvers = new Map() diff --git a/desktop/src/shared/services/api.service.ts b/desktop/src/shared/services/api.service.ts index a45bd345f..b5eaf7bfb 100644 --- a/desktop/src/shared/services/api.service.ts +++ b/desktop/src/shared/services/api.service.ts @@ -15,6 +15,7 @@ import { SequencePlan } from '../types/sequencer.types' import { PlateSolverOptions } from '../types/settings.types' import { FilterWheel } from '../types/wheel.types' import { HttpService } from './http.service' +import { ConnectionType } from '../types/home.types' @Injectable({ providedIn: 'root' }) export class ApiService { @@ -27,8 +28,8 @@ export class ApiService { // CONNECTION - connect(host: string, port: number) { - const query = this.http.query({ host, port }) + connect(host: string, port: number, type: ConnectionType) { + const query = this.http.query({ host, port, type }) return this.http.put(`connection?${query}`) } diff --git a/desktop/src/shared/services/preference.service.ts b/desktop/src/shared/services/preference.service.ts index 1e091b212..60b186f13 100644 --- a/desktop/src/shared/services/preference.service.ts +++ b/desktop/src/shared/services/preference.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core' import { AlignmentPreference, EMPTY_ALIGNMENT_PREFERENCE } from '../types/alignment.types' import { Camera, CameraPreference, CameraStartCapture, EMPTY_CAMERA_PREFERENCE } from '../types/camera.types' +import { ConnectionDetails, EMPTY_CONNECTION_DETAILS } from '../types/home.types' import { EMPTY_IMAGE_PREFERENCE, ImagePreference } from '../types/image.types' import { EMPTY_PLATE_SOLVER_OPTIONS, PlateSolverOptions, PlateSolverType } from '../types/settings.types' import { FilterWheel, WheelPreference } from '../types/wheel.types' @@ -61,7 +62,7 @@ export class PreferenceService { return new PreferenceData(this.storage, key, () => EMPTY_IMAGE_PREFERENCE) } - alignmentPreference() { - return new PreferenceData(this.storage, `alignment`, () => Object.assign({}, EMPTY_ALIGNMENT_PREFERENCE)) - } + readonly alignmentPreference = new PreferenceData(this.storage, `alignment`, () => Object.assign({}, EMPTY_ALIGNMENT_PREFERENCE)) + readonly lastConnectedHosts = new PreferenceData(this.storage, 'home.lastConnectedHosts', () => [Object.assign({}, EMPTY_CONNECTION_DETAILS)]) + readonly homeImageDefaultDirectory = new PreferenceData(this.storage, 'home.image.directory', '') } \ No newline at end of file diff --git a/desktop/src/shared/types/home.types.ts b/desktop/src/shared/types/home.types.ts index 4c71f3c11..09a67d685 100644 --- a/desktop/src/shared/types/home.types.ts +++ b/desktop/src/shared/types/home.types.ts @@ -1,13 +1,19 @@ export type HomeWindowType = 'CAMERA' | 'MOUNT' | 'GUIDER' | 'WHEEL' | 'FOCUSER' | 'DOME' | 'ROTATOR' | 'SWITCH' | 'SKY_ATLAS' | 'ALIGNMENT' | 'SEQUENCER' | 'IMAGE' | 'FRAMING' | 'INDI' | 'SETTINGS' | 'ABOUT' | 'FLAT_WIZARD' +export const CONNECTION_TYPES = ['INDI', 'ALPACA'] as const + +export type ConnectionType = (typeof CONNECTION_TYPES)[number] + export interface ConnectionDetails { host: string port: number + type: ConnectionType connectedAt?: number } export const EMPTY_CONNECTION_DETAILS: ConnectionDetails = { host: 'localhost', - port: 7624 + port: 7624, + type: 'INDI' } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt index 0ccd89172..e05b76583 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt @@ -40,11 +40,11 @@ class AlpacaClient( } override fun cameras(): List { - return cameras.values.toList() + return synchronized(cameras) { cameras.values.toList() } } override fun camera(name: String): Camera? { - return cameras[name] ?: cameras.values.find { it.name == name } + return synchronized(cameras) { cameras[name] ?: cameras.values.find { it.name == name } } } override fun mounts(): List { @@ -95,7 +95,6 @@ class AlpacaClient( return null } - @Synchronized fun discovery() { val response = service.management.configuredDevices().execute() @@ -106,9 +105,12 @@ class AlpacaClient( if (device.type == DeviceType.CAMERA) { if (device.uid in cameras) continue - with(ASCOMCamera(device, service.camera)) { - cameras[device.uid] = this - fireOnEventReceived(CameraAttached(this)) + synchronized(cameras) { + with(ASCOMCamera(device, service.camera)) { + cameras[device.uid] = this + LOG.info("camera attached: {}", device.name) + fireOnEventReceived(CameraAttached(this)) + } } } } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMCamera.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMCamera.kt index d013241f8..7ad44bfad 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMCamera.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMCamera.kt @@ -66,6 +66,8 @@ data class ASCOMCamera( override var canPulseGuide = false override var pulseGuiding = false + override var connected = false + override fun cooler(enabled: Boolean) { TODO("Not yet implemented") } @@ -126,9 +128,6 @@ data class ASCOMCamera( TODO("Not yet implemented") } - override val connected: Boolean - get() = TODO("Not yet implemented") - override fun connect() { TODO("Not yet implemented") } @@ -156,4 +155,22 @@ data class ASCOMCamera( override fun refresh() { TODO("Not yet implemented") } + + override fun toString() = "Camera(name=$name, connected=$connected, exposuring=$exposuring," + + " hasCoolerControl=$hasCoolerControl, cooler=$cooler," + + " hasDewHeater=$hasDewHeater, dewHeater=$dewHeater," + + " frameFormats=$frameFormats, canAbort=$canAbort," + + " cfaOffsetX=$cfaOffsetX, cfaOffsetY=$cfaOffsetY, cfaType=$cfaType," + + " exposureMin=$exposureMin, exposureMax=$exposureMax," + + " exposureState=$exposureState, exposureTime=$exposureTime," + + " hasCooler=$hasCooler, hasThermometer=$hasThermometer, canSetTemperature=$canSetTemperature," + + " temperature=$temperature, canSubFrame=$canSubFrame," + + " x=$x, minX=$minX, maxX=$maxX, y=$y, minY=$minY, maxY=$maxY," + + " width=$width, minWidth=$minWidth, maxWidth=$maxWidth, height=$height," + + " minHeight=$minHeight, maxHeight=$maxHeight," + + " canBin=$canBin, maxBinX=$maxBinX, maxBinY=$maxBinY," + + " binX=$binX, binY=$binY, gain=$gain, gainMin=$gainMin," + + " gainMax=$gainMax, offset=$offset, offsetMin=$offsetMin," + + " offsetMax=$offsetMax, hasGuiderHead=$hasGuiderHead," + + " canPulseGuide=$canPulseGuide, pulseGuiding=$pulseGuiding)" } From 6ac31fec707e09bc28e3c4ddd5b01bbcc3e4e4d6 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Tue, 13 Feb 2024 14:35:42 -0300 Subject: [PATCH 15/87] [api][desktop]: Add support to ASCOM Alpaca API --- .../beans/configurations/BeanConfiguration.kt | 10 +- .../api/connection/ConnectionService.kt | 2 +- desktop/src/app/camera/camera.component.html | 11 +- .../alpaca/api/AlpacaCameraService.kt | 239 +++++++ ...ce.kt => AlpacaDeviceManagementService.kt} | 2 +- ...eviceService.kt => AlpacaDeviceService.kt} | 2 +- .../nebulosa/alpaca/api/AlpacaService.kt | 6 +- ...raService.kt => AlpacaTelescopeService.kt} | 6 +- .../kotlin/nebulosa/alpaca/api/CameraState.kt | 13 + .../alpaca/api/CameraStateResponse.kt | 11 + .../nebulosa/alpaca/api/DoubleResponse.kt | 11 + .../kotlin/nebulosa/alpaca/api/IntResponse.kt | 11 + .../alpaca/api/PulseGuideDirection.kt | 11 + .../kotlin/nebulosa/alpaca/api/SensorType.kt | 13 + .../nebulosa/alpaca/api/SensorTypeResponse.kt | 11 + .../alpaca/api/StringArrayResponse.kt | 12 + .../nebulosa/alpaca/api/StringResponse.kt | 11 + .../nebulosa/alpaca/api/TelescopeService.kt | 14 - .../alpaca/indi/client/AlpacaClient.kt | 63 +- .../alpaca/indi/devices/ASCOMCamera.kt | 176 ------ .../alpaca/indi/devices/ASCOMDevice.kt | 133 +++- .../indi/devices/cameras/ASCOMCamera.kt | 581 ++++++++++++++++++ .../client/device/DeviceProtocolHandler.kt | 2 + .../indi/client/device/FilterWheelDevice.kt | 9 +- .../indi/client/device/FocuserDevice.kt | 38 +- .../nebulosa/indi/client/device/GPSDevice.kt | 17 +- .../nebulosa/indi/client/device/INDIDevice.kt | 2 +- .../indi/client/device/camera/CameraDevice.kt | 154 +++-- .../indi/client/device/mount/MountDevice.kt | 84 ++- .../kotlin/nebulosa/indi/device/DeviceHub.kt | 7 +- 30 files changed, 1337 insertions(+), 325 deletions(-) create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt rename nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/{ManagementService.kt => AlpacaDeviceManagementService.kt} (81%) rename nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/{DeviceService.kt => AlpacaDeviceService.kt} (81%) rename nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/{CameraService.kt => AlpacaTelescopeService.kt} (65%) create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraState.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraStateResponse.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DoubleResponse.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/IntResponse.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/PulseGuideDirection.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/SensorType.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/SensorTypeResponse.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/StringArrayResponse.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/StringResponse.kt delete mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/TelescopeService.kt delete mode 100644 nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMCamera.kt create mode 100644 nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt 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 b1f676b99..c1bbb08b8 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt @@ -20,6 +20,7 @@ import nebulosa.guiding.phd2.PHD2Guider import nebulosa.hips2fits.Hips2FitsService import nebulosa.horizons.HorizonsService import nebulosa.imaging.Image +import nebulosa.log.loggerFor import nebulosa.phd2.client.PHD2Client import nebulosa.sbd.SmallBodyDatabaseService import nebulosa.simbad.SimbadService @@ -93,10 +94,13 @@ class BeanConfiguration { fun cache(cachePath: Path) = Cache(cachePath.toFile(), MAX_CACHE_SIZE) @Bean - fun httpClient(connectionPool: ConnectionPool, cache: Cache) = OkHttpClient.Builder() + fun httpLogger() = HttpLoggingInterceptor.Logger { OKHTTP_LOGGER.debug(it) } + + @Bean + fun httpClient(connectionPool: ConnectionPool, cache: Cache, httpLogger: HttpLoggingInterceptor.Logger) = OkHttpClient.Builder() .connectionPool(connectionPool) .cache(cache) - .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC)) + .addInterceptor(HttpLoggingInterceptor(httpLogger).setLevel(HttpLoggingInterceptor.Level.BASIC)) .readTimeout(60L, TimeUnit.SECONDS) .writeTimeout(60L, TimeUnit.SECONDS) .connectTimeout(60L, TimeUnit.SECONDS) @@ -206,5 +210,7 @@ class BeanConfiguration { companion object { const val MAX_CACHE_SIZE = 1024L * 1024L * 32L // 32MB + + @JvmStatic private val OKHTTP_LOGGER = loggerFor() } } diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt index 806a37eaa..27fc12dcc 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt @@ -62,7 +62,7 @@ class ConnectionService( @Synchronized fun disconnect() { - (deviceHub as? Closeable)?.close() + deviceHub?.close() deviceHub = null } diff --git a/desktop/src/app/camera/camera.component.html b/desktop/src/app/camera/camera.component.html index 683571c9c..7c2b38f53 100644 --- a/desktop/src/app/camera/camera.component.html +++ b/desktop/src/app/camera/camera.component.html @@ -176,8 +176,9 @@
- +
@@ -191,9 +192,9 @@
- +
diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt new file mode 100644 index 000000000..d6efe3447 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt @@ -0,0 +1,239 @@ +package nebulosa.alpaca.api + +import retrofit2.Call +import retrofit2.http.* + +interface AlpacaCameraService : AlpacaDeviceService { + + @GET("api/v1/camera/{id}/connected") + override fun isConnected(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/connected") + override fun connect(@Path("id") id: Int, @Field("Connected") connected: Boolean): Call + + @GET("api/v1/camera/{id}/bayeroffsetx") + fun bayerOffsetX(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/bayeroffsety") + fun bayerOffsetY(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/binx") + fun binX(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/binx") + fun binX(@Path("id") id: Int, @Field("BinX") value: Int): Call + + @GET("api/v1/camera/{id}/biny") + fun binY(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/biny") + fun binY(@Path("id") id: Int, @Field("BinY") value: Int): Call + + @GET("api/v1/camera/{id}/camerastate") + fun cameraState(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/cameraxsize") + fun x(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/cameraysize") + fun y(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/canabortexposure") + fun canAbortExposure(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/canasymmetricbin") + fun canAsymmetricBin(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/canfastreadout") + fun canFastReadout(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/cangetcoolerpower") + fun canCoolerPower(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/canpulseguide") + fun canPulseGuide(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/cansetccdtemperature") + fun canSetCCDTemperature(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/canstopexposure") + fun canStopExposure(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/ccdtemperature") + fun ccdTemperature(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/cooleron") + fun isCoolerOn(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/cooleron") + fun cooler(@Path("id") id: Int, @Field("CoolerOn") value: Boolean): Call + + @GET("api/v1/camera/{id}/coolerpower") + fun coolerPower(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/electronsperadu") + fun electronsPerADU(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/exposuremax") + fun exposureMax(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/exposuremin") + fun exposureMin(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/exposureresolution") + fun exposureResolution(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/fastreadout") + fun isFastReadout(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/fastreadout") + fun fastReadout(@Path("id") id: Int, @Field("FastReadout") value: Boolean): Call + + @GET("api/v1/camera/{id}/fullwellcapacity") + fun fullWellCapacity(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/gain") + fun gain(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/gain") + fun gain(@Path("id") id: Int, @Field("Gain") value: Int): Call + + @GET("api/v1/camera/{id}/gainmax") + fun gainMax(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/gainmin") + fun gainMin(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/gains") + fun gains(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/hasshutter") + fun hasShutter(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/heatsinktemperature") + fun heatSinkTemperature(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/imageready") + fun isImageReady(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/ispulseguiding") + fun isPulseGuiding(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/lastexposureduration") + fun lastExposureDuration(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/lastexposurestarttime") + fun lastExposureStartTime(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/maxadu") + fun maxADU(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/maxbinx") + fun maxBinX(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/maxbiny") + fun maxBinY(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/numx") + fun numX(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/numx") + fun numX(@Path("id") id: Int, @Field("NumX") value: Int): Call + + @GET("api/v1/camera/{id}/numy") + fun numY(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/numy") + fun numY(@Path("id") id: Int, @Field("NumY") value: Int): Call + + @GET("api/v1/camera/{id}/offset") + fun offset(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/offset") + fun offset(@Path("id") id: Int, @Field("Offset") value: Int): Call + + @GET("api/v1/camera/{id}/offsetmax") + fun offsetMax(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/offsetmin") + fun offsetMin(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/offsets") + fun offsets(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/percentcompleted") + fun percentCompleted(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/pixelsizex") + fun pixelSizeX(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/pixelsizey") + fun pixelSizeY(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/readoutmode") + fun readoutMode(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/readoutmode") + fun readoutMode(@Path("id") id: Int, @Field("ReadoutMode") value: Int): Call + + @GET("api/v1/camera/{id}/readoutmodes") + fun readoutModes(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/sensorname") + fun sensorName(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/sensortype") + fun sensorType(@Path("id") id: Int): Call + + @GET("api/v1/camera/{id}/setccdtemperature") + fun setpointCCDTemperature(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/setccdtemperature") + fun setpointCCDTemperature(@Path("id") id: Int, @Field("SetCCDTemperature") value: Double): Call + + @GET("api/v1/camera/{id}/startx") + fun startX(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/startx") + fun startX(@Path("id") id: Int, @Field("StartX") value: Int): Call + + @GET("api/v1/camera/{id}/starty") + fun startY(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/starty") + fun startY(@Path("id") id: Int, @Field("StartY") value: Int): Call + + @GET("api/v1/camera/{id}/subexposureduration") + fun subExposureDuration(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/subexposureduration") + fun subExposureDuration(@Path("id") id: Int, @Field("SubExposureDuration") value: Double): Call + + @PUT("api/v1/camera/{id}/abortexposure") + fun abortExposure(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/pulseguide") + fun pulseGuide(@Path("id") id: Int, @Field("Direction") direction: PulseGuideDirection, @Field("Duration") durationMs: Long): Call + + @FormUrlEncoded + @PUT("api/v1/camera/{id}/startexposure") + fun startExposure(@Path("id") id: Int, @Field("Duration") durationInSeconds: Double, @Field("Light") light: Boolean): Call + + @PUT("api/v1/camera/{id}/stopexposure") + fun stopExposure(@Path("id") id: Int): Call +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ManagementService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaDeviceManagementService.kt similarity index 81% rename from nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ManagementService.kt rename to nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaDeviceManagementService.kt index d95e34e08..db09d948d 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ManagementService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaDeviceManagementService.kt @@ -3,7 +3,7 @@ package nebulosa.alpaca.api import retrofit2.Call import retrofit2.http.GET -interface ManagementService { +interface AlpacaDeviceManagementService { @GET("management/v1/configureddevices") fun configuredDevices(): Call> diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DeviceService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaDeviceService.kt similarity index 81% rename from nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DeviceService.kt rename to nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaDeviceService.kt index a627c535b..aab1de24b 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DeviceService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaDeviceService.kt @@ -2,7 +2,7 @@ package nebulosa.alpaca.api import retrofit2.Call -sealed interface DeviceService { +sealed interface AlpacaDeviceService { fun isConnected(id: Int): Call diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt index cbc95700f..9d5b2aeab 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt @@ -15,9 +15,9 @@ class AlpacaService( httpClient: OkHttpClient? = null, ) : RetrofitService(url, httpClient) { - val management by lazy { retrofit.create() } + val management by lazy { retrofit.create() } - val camera by lazy { retrofit.create() } + val camera by lazy { retrofit.create() } - val telescope by lazy { retrofit.create() } + val telescope by lazy { retrofit.create() } } diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt similarity index 65% rename from nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraService.kt rename to nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt index ddd98bd84..2a786737a 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt @@ -3,12 +3,12 @@ package nebulosa.alpaca.api import retrofit2.Call import retrofit2.http.* -interface CameraService : DeviceService { +interface AlpacaTelescopeService : AlpacaDeviceService { - @GET("camera/{id}/connected") + @GET("api/v1/telescope/{id}/connected") override fun isConnected(@Path("id") id: Int): Call @FormUrlEncoded - @PUT("camera/{id}/connected") + @PUT("api/v1/telescope/{id}/connected") override fun connect(@Path("id") id: Int, @Field("Connected") connected: Boolean): Call } diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraState.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraState.kt new file mode 100644 index 000000000..2a54a4272 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraState.kt @@ -0,0 +1,13 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonFormat + +@JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) +enum class CameraState { + IDLE, + WAITING, + EXPOSURING, + READING, + DOWNLOAD, + ERROR, +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraStateResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraStateResponse.kt new file mode 100644 index 000000000..c1584c1ba --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/CameraStateResponse.kt @@ -0,0 +1,11 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +data class CameraStateResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", + @field:JsonProperty("Value") override val value: CameraState = CameraState.IDLE, +) : AlpacaResponse diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DoubleResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DoubleResponse.kt new file mode 100644 index 000000000..69e07864f --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DoubleResponse.kt @@ -0,0 +1,11 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +data class DoubleResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", + @field:JsonProperty("Value") override val value: Double = 0.0, +) : AlpacaResponse diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/IntResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/IntResponse.kt new file mode 100644 index 000000000..3c5b0c294 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/IntResponse.kt @@ -0,0 +1,11 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +data class IntResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", + @field:JsonProperty("Value") override val value: Int = 0, +) : AlpacaResponse diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/PulseGuideDirection.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/PulseGuideDirection.kt new file mode 100644 index 000000000..a41bb08be --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/PulseGuideDirection.kt @@ -0,0 +1,11 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonFormat + +@JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) +enum class PulseGuideDirection { + NORTH, + SOUTH, + EAST, + WEST, +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/SensorType.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/SensorType.kt new file mode 100644 index 000000000..669484310 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/SensorType.kt @@ -0,0 +1,13 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonFormat + +@JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) +enum class SensorType { + MONOCHROME, + NO_COLOR, + RGGB, + CMYB, + CMYG2, + LRGB_TRUESENSE, +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/SensorTypeResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/SensorTypeResponse.kt new file mode 100644 index 000000000..378a76206 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/SensorTypeResponse.kt @@ -0,0 +1,11 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +data class SensorTypeResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", + @field:JsonProperty("Value") override val value: SensorType = SensorType.MONOCHROME, +) : AlpacaResponse diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/StringArrayResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/StringArrayResponse.kt new file mode 100644 index 000000000..c0183d7ce --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/StringArrayResponse.kt @@ -0,0 +1,12 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +@Suppress("ArrayInDataClass") +data class StringArrayResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", + @field:JsonProperty("Value") override val value: Array = emptyArray(), +) : AlpacaResponse> diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/StringResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/StringResponse.kt new file mode 100644 index 000000000..e449e4045 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/StringResponse.kt @@ -0,0 +1,11 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +data class StringResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", + @field:JsonProperty("Value") override val value: String = "", +) : AlpacaResponse diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/TelescopeService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/TelescopeService.kt deleted file mode 100644 index 906ba6b6b..000000000 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/TelescopeService.kt +++ /dev/null @@ -1,14 +0,0 @@ -package nebulosa.alpaca.api - -import retrofit2.Call -import retrofit2.http.* - -interface TelescopeService : DeviceService { - - @GET("telescope/{id}/connected") - override fun isConnected(@Path("id") id: Int): Call - - @FormUrlEncoded - @PUT("telescope/{id}/connected") - override fun connect(@Path("id") id: Int, @Field("Connected") connected: Boolean): Call -} diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt index e05b76583..a042a8b01 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt @@ -2,16 +2,19 @@ package nebulosa.alpaca.indi.client import nebulosa.alpaca.api.AlpacaService import nebulosa.alpaca.api.DeviceType -import nebulosa.alpaca.indi.devices.ASCOMCamera +import nebulosa.alpaca.indi.devices.ASCOMDevice +import nebulosa.alpaca.indi.devices.cameras.ASCOMCamera import nebulosa.indi.device.DeviceEvent import nebulosa.indi.device.DeviceEventHandler import nebulosa.indi.device.DeviceHub import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.CameraAttached +import nebulosa.indi.device.camera.CameraDetached import nebulosa.indi.device.filterwheel.FilterWheel import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.gps.GPS import nebulosa.indi.device.guide.GuideOutput +import nebulosa.indi.device.guide.GuideOutputAttached import nebulosa.indi.device.mount.Mount import nebulosa.indi.device.thermometer.Thermometer import nebulosa.log.loggerFor @@ -25,17 +28,18 @@ class AlpacaClient( private val service = AlpacaService("http://$host:$port/", httpClient) private val handlers = LinkedHashSet() - private val cameras = HashMap() + private val cameras = HashMap() + private val guideOutputs = HashMap() - fun registerDeviceEventHandler(handler: DeviceEventHandler) { + override fun registerDeviceEventHandler(handler: DeviceEventHandler) { handlers.add(handler) } - fun unregisterDeviceEventHandler(handler: DeviceEventHandler) { + override fun unregisterDeviceEventHandler(handler: DeviceEventHandler) { handlers.remove(handler) } - fun fireOnEventReceived(event: DeviceEvent<*>) { + internal fun fireOnEventReceived(event: DeviceEvent<*>) { handlers.forEach { it.onEventReceived(event) } } @@ -106,7 +110,7 @@ class AlpacaClient( if (device.uid in cameras) continue synchronized(cameras) { - with(ASCOMCamera(device, service.camera)) { + with(ASCOMCamera(device, service.camera, this)) { cameras[device.uid] = this LOG.info("camera attached: {}", device.name) fireOnEventReceived(CameraAttached(this)) @@ -121,6 +125,53 @@ class AlpacaClient( } } + internal fun registerGuideOutput(device: GuideOutput) { + if (device is ASCOMDevice) { + guideOutputs[device.uid] = device + fireOnEventReceived(GuideOutputAttached(device)) + } + } + + override fun close() { + for ((_, device) in cameras) { + device.close() + LOG.info("camera detached: {}", device.name) + fireOnEventReceived(CameraDetached(device)) + } + + // for ((_, device) in mounts) { + // device.close() + // LOG.info("mount detached: {}", device.name) + // fireOnEventReceived(MountDetached(device)) + // } + + // for ((_, device) in wheels) { + // device.close() + // LOG.info("filter wheel detached: {}", device.name) + // fireOnEventReceived(FilterWheelDetached(device)) + // } + + // for ((_, device) in focusers) { + // device.close() + // LOG.info("focuser detached: {}", device.name) + // fireOnEventReceived(FocuserDetached(device)) + // } + + // for ((_, device) in gps) { + // device.close() + // LOG.info("gps detached: {}", device.name) + // fireOnEventReceived(GPSDetached(device)) + // } + + cameras.clear() + // mounts.clear() + // wheels.clear() + // focusers.clear() + // gps.clear() + + handlers.clear() + } + companion object { @JvmStatic private val LOG = loggerFor() diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMCamera.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMCamera.kt deleted file mode 100644 index 7ad44bfad..000000000 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMCamera.kt +++ /dev/null @@ -1,176 +0,0 @@ -package nebulosa.alpaca.indi.devices - -import nebulosa.alpaca.api.CameraService -import nebulosa.alpaca.api.ConfiguredDevice -import nebulosa.imaging.algorithms.transformation.CfaPattern -import nebulosa.indi.device.Device -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.camera.FrameType -import nebulosa.indi.protocol.INDIProtocol -import nebulosa.indi.protocol.PropertyState -import java.time.Duration - -data class ASCOMCamera( - override val device: ConfiguredDevice, - override val service: CameraService, -) : ASCOMDevice(), Camera { - - override var exposuring = false - override var hasCoolerControl = false - override var coolerPower = 0.0 - override var cooler = false - override var hasDewHeater = false - override var dewHeater = false - override var frameFormats = emptyList() - override var canAbort = false - override var cfaOffsetX = 0 - override var cfaOffsetY = 0 - override var cfaType = CfaPattern.RGGB - override var exposureMin: Duration = Duration.ZERO - override var exposureMax: Duration = Duration.ZERO - override var exposureState = PropertyState.IDLE - override var exposureTime: Duration = Duration.ZERO - override var hasCooler = false - override var canSetTemperature = false - override var canSubFrame = false - override var x = 0 - override var minX = 0 - override var maxX = 0 - override var y = 0 - override var minY = 0 - override var maxY = 0 - override var width = 0 - override var minWidth = 0 - override var maxWidth = 0 - override var height = 0 - override var minHeight = 0 - override var maxHeight = 0 - override var canBin = false - override var maxBinX = 1 - override var maxBinY = 1 - override var binX = 1 - override var binY = 1 - override var gain = 0 - override var gainMin = 0 - override var gainMax = 0 - override var offset = 0 - override var offsetMin = 0 - override var offsetMax = 0 - override var hasGuiderHead = false // TODO: ASCOM has guider head? - override var pixelSizeX = 0.0 - override var pixelSizeY = 0.0 - - override var hasThermometer = false - override var temperature = 0.0 - - override var canPulseGuide = false - override var pulseGuiding = false - - override var connected = false - - override fun cooler(enabled: Boolean) { - TODO("Not yet implemented") - } - - override fun dewHeater(enabled: Boolean) { - TODO("Not yet implemented") - } - - override fun temperature(value: Double) { - TODO("Not yet implemented") - } - - override fun frameFormat(format: String?) { - TODO("Not yet implemented") - } - - override fun frameType(type: FrameType) { - TODO("Not yet implemented") - } - - override fun frame(x: Int, y: Int, width: Int, height: Int) { - TODO("Not yet implemented") - } - - override fun bin(x: Int, y: Int) { - TODO("Not yet implemented") - } - - override fun gain(value: Int) { - TODO("Not yet implemented") - } - - override fun offset(value: Int) { - TODO("Not yet implemented") - } - - override fun startCapture(exposureTime: Duration) { - TODO("Not yet implemented") - } - - override fun abortCapture() { - TODO("Not yet implemented") - } - - override fun guideNorth(duration: Duration) { - TODO("Not yet implemented") - } - - override fun guideSouth(duration: Duration) { - TODO("Not yet implemented") - } - - override fun guideEast(duration: Duration) { - TODO("Not yet implemented") - } - - override fun guideWest(duration: Duration) { - TODO("Not yet implemented") - } - - override fun connect() { - TODO("Not yet implemented") - } - - override fun disconnect() { - TODO("Not yet implemented") - } - - override fun sendMessageToServer(message: INDIProtocol) { - TODO("Not yet implemented") - } - - override fun snoop(devices: Iterable) { - TODO("Not yet implemented") - } - - override fun handleMessage(message: INDIProtocol) { - TODO("Not yet implemented") - } - - override fun close() { - TODO("Not yet implemented") - } - - override fun refresh() { - TODO("Not yet implemented") - } - - override fun toString() = "Camera(name=$name, connected=$connected, exposuring=$exposuring," + - " hasCoolerControl=$hasCoolerControl, cooler=$cooler," + - " hasDewHeater=$hasDewHeater, dewHeater=$dewHeater," + - " frameFormats=$frameFormats, canAbort=$canAbort," + - " cfaOffsetX=$cfaOffsetX, cfaOffsetY=$cfaOffsetY, cfaType=$cfaType," + - " exposureMin=$exposureMin, exposureMax=$exposureMax," + - " exposureState=$exposureState, exposureTime=$exposureTime," + - " hasCooler=$hasCooler, hasThermometer=$hasThermometer, canSetTemperature=$canSetTemperature," + - " temperature=$temperature, canSubFrame=$canSubFrame," + - " x=$x, minX=$minX, maxX=$maxX, y=$y, minY=$minY, maxY=$maxY," + - " width=$width, minWidth=$minWidth, maxWidth=$maxWidth, height=$height," + - " minHeight=$minHeight, maxHeight=$maxHeight," + - " canBin=$canBin, maxBinX=$maxBinX, maxBinY=$maxBinY," + - " binX=$binX, binY=$binY, gain=$gain, gainMin=$gainMin," + - " gainMax=$gainMax, offset=$offset, offsetMin=$offsetMin," + - " offsetMax=$offsetMax, hasGuiderHead=$hasGuiderHead," + - " canPulseGuide=$canPulseGuide, pulseGuiding=$pulseGuiding)" -} diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt index 0ee2ca8ce..14c5fa661 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt @@ -1,20 +1,141 @@ package nebulosa.alpaca.indi.devices +import nebulosa.alpaca.api.AlpacaDeviceService +import nebulosa.alpaca.api.AlpacaResponse import nebulosa.alpaca.api.ConfiguredDevice -import nebulosa.alpaca.api.DeviceService -import nebulosa.indi.device.Device -import nebulosa.indi.device.PropertyVector +import nebulosa.alpaca.indi.client.AlpacaClient +import nebulosa.common.time.Stopwatch +import nebulosa.indi.device.* +import nebulosa.log.loggerFor +import retrofit2.Call +import retrofit2.HttpException +import java.time.LocalDateTime +import java.util.* abstract class ASCOMDevice : Device { protected abstract val device: ConfiguredDevice - protected abstract val service: DeviceService + protected abstract val service: AlpacaDeviceService + protected abstract val client: AlpacaClient + + @Suppress("PropertyName") + @JvmField protected val LOG = loggerFor(javaClass) override val name get() = device.name + val uid + get() = device.uid + + @Volatile final override var connected = false + private set + override val properties = emptyMap>() - override val messages = emptyList() + override val messages = LinkedList() + + @Volatile private var refresher: Refresher? = null + + override fun connect() { + executeRequest(service.connect(device.number, true)) + } + + override fun disconnect() { + executeRequest(service.connect(device.number, false)) + } + + open fun refresh(elapsedTimeInSeconds: Long) { + executeRequest(service.isConnected(device.number)) { processConnected(it.value) } + } + + open fun reset() { + connected = false + } + + override fun close() { + refresher?.interrupt() + refresher = null + } + + protected abstract fun onConnected() + + protected abstract fun onDisconnected() + + private fun addMessageAndFireEvent(text: String) { + messages.addFirst(text) + client.fireOnEventReceived(DeviceMessageReceived(this, text)) + } + + protected fun > executeRequest(call: Call): T? { + try { + val response = call.execute().body() + + if (response == null) { + LOG.warn("response has no body. device={}", name) + return null + } else if (response.errorNumber != 0) { + val message = response.errorMessage + + if (message.isNotEmpty()) { + addMessageAndFireEvent("[%s]: %s".format(LocalDateTime.now(), message)) + } + + LOG.warn("unsuccessful response. device={}, code={}, message={}", name, response.errorNumber, response.errorMessage) + return null + } else { + return response + } + } catch (e: HttpException) { + LOG.error("unexpected response. device=$name", e) + } catch (e: Throwable) { + LOG.error("unexpected error. device=$name", e) + } + + return null + } + + protected inline fun > executeRequest(call: Call, action: (T) -> Unit): Boolean { + return executeRequest(call)?.also(action) != null + } + + protected fun processConnected(value: Boolean) { + if (connected != value) { + connected = value + + if (value) { + client.fireOnEventReceived(DeviceConnected(this)) + + onConnected() + + if (refresher == null) { + refresher = Refresher() + refresher!!.start() + } + } else { + client.fireOnEventReceived(DeviceDisconnected(this)) + + onDisconnected() + + refresher?.interrupt() + refresher = null + } + } + } + + private inner class Refresher : Thread() { + + private val stopwatch = Stopwatch() + + override fun run() { + stopwatch.start() + + val startTime = System.currentTimeMillis() + refresh(stopwatch.elapsedSeconds) + val endTime = System.currentTimeMillis() + val delayTime = 1000L - (endTime - startTime) - abstract fun refresh() + if (delayTime > 1L) { + sleep(delayTime) + } + } + } } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt new file mode 100644 index 000000000..531ab6ee2 --- /dev/null +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt @@ -0,0 +1,581 @@ +package nebulosa.alpaca.indi.devices.cameras + +import nebulosa.alpaca.api.AlpacaCameraService +import nebulosa.alpaca.api.CameraState +import nebulosa.alpaca.api.ConfiguredDevice +import nebulosa.alpaca.api.PulseGuideDirection +import nebulosa.alpaca.indi.client.AlpacaClient +import nebulosa.alpaca.indi.devices.ASCOMDevice +import nebulosa.imaging.algorithms.transformation.CfaPattern +import nebulosa.indi.device.Device +import nebulosa.indi.device.camera.* +import nebulosa.indi.device.guide.GuideOutputPulsingChanged +import nebulosa.indi.protocol.INDIProtocol +import nebulosa.indi.protocol.PropertyState +import java.time.Duration +import kotlin.math.max +import kotlin.math.min + +data class ASCOMCamera( + override val device: ConfiguredDevice, + override val service: AlpacaCameraService, + override val client: AlpacaClient, +) : ASCOMDevice(), Camera { + + @Volatile override var exposuring = false + private set + @Volatile override var hasCoolerControl = false + private set + @Volatile override var coolerPower = 0.0 + private set + @Volatile override var cooler = false + private set + @Volatile override var hasDewHeater = false + private set + @Volatile override var dewHeater = false + private set + @Volatile override var frameFormats = emptyList() + private set + @Volatile override var canAbort = false + private set + @Volatile override var cfaOffsetX = 0 + private set + @Volatile override var cfaOffsetY = 0 + private set + @Volatile override var cfaType = CfaPattern.RGGB + private set + @Volatile override var exposureMin: Duration = Duration.ZERO + private set + @Volatile override var exposureMax: Duration = Duration.ZERO + private set + @Volatile override var exposureState = PropertyState.IDLE + private set + @Volatile override var exposureTime: Duration = Duration.ZERO + private set + @Volatile override var hasCooler = false + private set + @Volatile override var canSetTemperature = false + private set + @Volatile override var canSubFrame = true + private set + @Volatile override var x = 0 + private set + @Volatile override var minX = 0 + private set + @Volatile override var maxX = 0 + private set + @Volatile override var y = 0 + private set + @Volatile override var minY = 0 + private set + @Volatile override var maxY = 0 + private set + @Volatile override var width = 0 + private set + @Volatile override var minWidth = 0 + private set + @Volatile override var maxWidth = 0 + private set + @Volatile override var height = 0 + private set + @Volatile override var minHeight = 0 + private set + @Volatile override var maxHeight = 0 + private set + @Volatile override var canBin = true + private set + @Volatile override var maxBinX = 1 + private set + @Volatile override var maxBinY = 1 + private set + @Volatile override var binX = 1 + private set + @Volatile override var binY = 1 + private set + @Volatile override var gain = 0 + private set + @Volatile override var gainMin = 0 + private set + @Volatile override var gainMax = 0 + private set + @Volatile override var offset = 0 + private set + @Volatile override var offsetMin = 0 + private set + @Volatile override var offsetMax = 0 + private set + @Volatile override var hasGuiderHead = false // TODO: ASCOM has guider head? + private set + @Volatile override var pixelSizeX = 0.0 + private set + @Volatile override var pixelSizeY = 0.0 + private set + + @Volatile override var hasThermometer = false + private set + @Volatile override var temperature = 0.0 + private set + + @Volatile override var canPulseGuide = false + private set + @Volatile override var pulseGuiding = false + private set + + @Volatile private var cameraState = CameraState.IDLE + @Volatile private var frameType = FrameType.LIGHT + + init { + refresh(0L) + } + + override fun cooler(enabled: Boolean) { + executeRequest(service.cooler(device.number, enabled)) + } + + override fun dewHeater(enabled: Boolean) { + // TODO + } + + override fun temperature(value: Double) { + executeRequest(service.setpointCCDTemperature(device.number, value)) + } + + override fun frameFormat(format: String?) { + // TODO + } + + override fun frameType(type: FrameType) { + frameType = type + } + + override fun frame(x: Int, y: Int, width: Int, height: Int) { + executeRequest(service.startX(device.number, x)) ?: return + executeRequest(service.startY(device.number, y)) ?: return + executeRequest(service.numX(device.number, width)) ?: return + executeRequest(service.numY(device.number, height)) + } + + override fun bin(x: Int, y: Int) { + executeRequest(service.binX(device.number, x)) ?: return + executeRequest(service.binY(device.number, y)) + } + + override fun gain(value: Int) { + executeRequest(service.gain(device.number, value)) + } + + override fun offset(value: Int) { + executeRequest(service.offset(device.number, value)) + } + + override fun startCapture(exposureTime: Duration) { + this.exposureTime = exposureTime + executeRequest(service.startExposure(device.number, exposureTime.toNanos() / 1000000000.0, frameType == FrameType.DARK)) + } + + override fun abortCapture() { + executeRequest(service.abortExposure(device.number)) + } + + private fun pulseGuide(direction: PulseGuideDirection, duration: Duration) { + val durationInMilliseconds = duration.toMillis() + + executeRequest(service.pulseGuide(device.number, direction, durationInMilliseconds)) ?: return + + if (durationInMilliseconds > 0) { + pulseGuiding = true + client.fireOnEventReceived(GuideOutputPulsingChanged(this)) + } + } + + override fun guideNorth(duration: Duration) { + pulseGuide(PulseGuideDirection.NORTH, duration) + } + + override fun guideSouth(duration: Duration) { + pulseGuide(PulseGuideDirection.SOUTH, duration) + } + + override fun guideEast(duration: Duration) { + pulseGuide(PulseGuideDirection.EAST, duration) + } + + override fun guideWest(duration: Duration) { + pulseGuide(PulseGuideDirection.WEST, duration) + } + + override fun sendMessageToServer(message: INDIProtocol) { + } + + override fun snoop(devices: Iterable) { + } + + override fun handleMessage(message: INDIProtocol) { + } + + override fun onConnected() { + processExposureMinMax() + processFrameMinMax() + processGainMinMax() + processOffsetMinMax() + processCapabilities() + processPixelSize() + processCfaOffset() + } + + override fun onDisconnected() { + } + + override fun reset() { + super.reset() + + exposuring = false + hasCoolerControl = false + coolerPower = 0.0 + cooler = false + hasDewHeater = false + dewHeater = false + frameFormats = emptyList() + canAbort = false + cfaOffsetX = 0 + cfaOffsetY = 0 + cfaType = CfaPattern.RGGB + exposureMin = Duration.ZERO + exposureMax = Duration.ZERO + exposureState = PropertyState.IDLE + exposureTime = Duration.ZERO + hasCooler = false + canSetTemperature = false + canSubFrame = true + x = 0 + minX = 0 + maxX = 0 + y = 0 + minY = 0 + maxY = 0 + width = 0 + minWidth = 0 + maxWidth = 0 + height = 0 + minHeight = 0 + maxHeight = 0 + canBin = true + maxBinX = 1 + maxBinY = 1 + binX = 1 + binY = 1 + gain = 0 + gainMin = 0 + gainMax = 0 + offset = 0 + offsetMin = 0 + offsetMax = 0 + hasGuiderHead = false + pixelSizeX = 0.0 + pixelSizeY = 0.0 + hasThermometer = false + temperature = 0.0 + canPulseGuide = false + pulseGuiding = false + cameraState = CameraState.IDLE + } + + override fun close() { + super.close() + reset() + } + + override fun refresh(elapsedTimeInSeconds: Long) { + super.refresh(elapsedTimeInSeconds) + + executeRequest(service.cameraState(device.number)) { processCameraState(it.value) } + + processBin() + processGain() + processOffset() + processCooler() + } + + private fun processCameraState(value: CameraState) { + if (cameraState != value) { + cameraState = value + + val prevExposuring = exposuring + val prevExposureState = exposureState + + when (value) { + CameraState.IDLE -> { + if (exposuring) { + exposuring = false + exposureState = PropertyState.IDLE + } + } + CameraState.WAITING -> { + if (exposuring) { + exposuring = false + exposureState = PropertyState.BUSY + } + } + CameraState.EXPOSURING -> { + if (!exposuring) { + exposuring = true + exposureState = PropertyState.BUSY + } + } + CameraState.READING -> { + if (exposuring) { + exposuring = false + exposureState = PropertyState.OK + } + } + CameraState.DOWNLOAD -> { + if (exposuring) { + exposuring = false + exposureState = PropertyState.OK + } + } + CameraState.ERROR -> { + if (exposuring) { + exposuring = false + exposureState = PropertyState.ALERT + } + } + } + + if (prevExposuring != exposuring) client.fireOnEventReceived(CameraExposuringChanged(this)) + if (prevExposureState != exposureState) client.fireOnEventReceived(CameraExposureStateChanged(this, prevExposureState)) + + if (exposuring) { + executeRequest(service.percentCompleted(device.number)) { + + } + } + + if (exposureState == PropertyState.IDLE && (prevExposureState == PropertyState.BUSY || exposuring)) { + client.fireOnEventReceived(CameraExposureAborted(this)) + } else if (exposureState == PropertyState.OK && prevExposureState == PropertyState.BUSY) { + client.fireOnEventReceived(CameraExposureFinished(this)) + } else if (exposureState == PropertyState.ALERT && prevExposureState != PropertyState.ALERT) { + client.fireOnEventReceived(CameraExposureFailed(this)) + } + } + } + + private fun processBin() { + executeRequest(service.binX(device.number)) { x -> + executeRequest(service.binY(device.number)) { y -> + if (x.value != binX || y.value != binY) { + binX = x.value + binY = y.value + + client.fireOnEventReceived(CameraBinChanged(this)) + } + } + } + } + + private fun processGainMinMax() { + executeRequest(service.gainMin(device.number)) { min -> + executeRequest(service.gainMax(device.number)) { max -> + gainMin = min.value + gainMax = max.value + gain = max(gainMin, min(gain, gainMax)) + + client.fireOnEventReceived(CameraGainMinMaxChanged(this)) + } + } + } + + private fun processGain() { + executeRequest(service.gain(device.number)) { + if (it.value != gain) { + gain = it.value + + client.fireOnEventReceived(CameraGainChanged(this)) + } + } + } + + private fun processOffsetMinMax() { + executeRequest(service.offsetMin(device.number)) { min -> + executeRequest(service.offsetMax(device.number)) { max -> + offsetMin = min.value + offsetMax = max.value + offset = max(offsetMin, min(offset, offsetMax)) + + client.fireOnEventReceived(CameraOffsetMinMaxChanged(this)) + } + } + } + + private fun processOffset() { + executeRequest(service.offset(device.number)) { + if (it.value != offset) { + offset = it.value + + client.fireOnEventReceived(CameraOffsetChanged(this)) + } + } + } + + private fun processFrameMinMax() { + executeRequest(service.x(device.number)) { w -> + executeRequest(service.y(device.number)) { h -> + width = w.value + height = h.value + minWidth = 0 + maxWidth = width + minHeight = 0 + maxHeight = height + x = 0 + minX = 0 + maxX = width - 1 + y = 0 + minY = 0 + maxY = height - 1 + + if (!processFrame()) { + client.fireOnEventReceived(CameraFrameChanged(this)) + } + } + } + } + + private fun processFrame(): Boolean { + executeRequest(service.numX(device.number)) { w -> + executeRequest(service.numY(device.number)) { h -> + executeRequest(service.startX(device.number)) { x -> + executeRequest(service.startY(device.number)) { y -> + if (w.value != width || h.value != height || x.value != this.x || y.value != this.y) { + width = w.value + height = h.value + this.x = x.value + this.y = y.value + + client.fireOnEventReceived(CameraFrameChanged(this)) + + return true + } + } + } + } + } + + return false + } + + private fun processCooler() { + if (hasCoolerControl) { + executeRequest(service.coolerPower(device.number)) { + if (coolerPower != it.value) { + coolerPower = it.value + + client.fireOnEventReceived(CameraCoolerPowerChanged(this)) + } + } + } + + if (hasCooler) { + executeRequest(service.isCoolerOn(device.number)) { + if (cooler != it.value) { + cooler = it.value + + client.fireOnEventReceived(CameraCoolerChanged(this)) + } + } + } + } + + private fun processPixelSize() { + executeRequest(service.pixelSizeX(device.number)) { x -> + executeRequest(service.pixelSizeY(device.number)) { y -> + if (pixelSizeX != x.value || pixelSizeY != y.value) { + pixelSizeX = x.value + pixelSizeY = y.value + + client.fireOnEventReceived(CameraPixelSizeChanged(this)) + } + } + } + } + + private fun processCfaOffset() { + executeRequest(service.bayerOffsetX(device.number)) { x -> + executeRequest(service.bayerOffsetY(device.number)) { y -> + if (cfaOffsetX != x.value || cfaOffsetY != y.value) { + cfaOffsetX = x.value + cfaOffsetY = y.value + + client.fireOnEventReceived(CameraCfaChanged(this)) + } + } + } + } + + private fun processExposureMinMax() { + executeRequest(service.exposureMin(device.number)) { min -> + executeRequest(service.exposureMax(device.number)) { max -> + exposureMin = Duration.ofNanos((min.value * 1000000000.0).toLong()) + exposureMax = Duration.ofNanos((max.value * 1000000000.0).toLong()) + + client.fireOnEventReceived(CameraExposureMinMaxChanged(this)) + } + } + } + + private fun processCapabilities() { + executeRequest(service.canAbortExposure(device.number)) { + if (it.value != canAbort) { + canAbort = it.value + + client.fireOnEventReceived(CameraCanAbortChanged(this)) + } + } + + executeRequest(service.canCoolerPower(device.number)) { + if (it.value != hasCoolerControl) { + hasCoolerControl = it.value + + client.fireOnEventReceived(CameraCoolerControlChanged(this)) + } + } + + executeRequest(service.canPulseGuide(device.number)) { + if (it.value != canPulseGuide) { + canPulseGuide = it.value + + client.registerGuideOutput(this) + + LOG.info("guide output attached: {}", name) + } + } + + executeRequest(service.canSetCCDTemperature(device.number)) { + if (it.value != canSetTemperature) { + canSetTemperature = it.value + hasCooler = canSetTemperature + + client.fireOnEventReceived(CameraHasCoolerChanged(this)) + client.fireOnEventReceived(CameraCanSetTemperatureChanged(this)) + } + } + } + + override fun toString() = "Camera(name=$name, connected=$connected, exposuring=$exposuring," + + " hasCoolerControl=$hasCoolerControl, cooler=$cooler," + + " hasDewHeater=$hasDewHeater, dewHeater=$dewHeater," + + " frameFormats=$frameFormats, canAbort=$canAbort," + + " cfaOffsetX=$cfaOffsetX, cfaOffsetY=$cfaOffsetY, cfaType=$cfaType," + + " exposureMin=$exposureMin, exposureMax=$exposureMax," + + " exposureState=$exposureState, exposureTime=$exposureTime," + + " hasCooler=$hasCooler, hasThermometer=$hasThermometer, canSetTemperature=$canSetTemperature," + + " temperature=$temperature, canSubFrame=$canSubFrame," + + " x=$x, minX=$minX, maxX=$maxX, y=$y, minY=$minY, maxY=$maxY," + + " width=$width, minWidth=$minWidth, maxWidth=$maxWidth, height=$height," + + " minHeight=$minHeight, maxHeight=$maxHeight," + + " canBin=$canBin, maxBinX=$maxBinX, maxBinY=$maxBinY," + + " binX=$binX, binY=$binY, gain=$gain, gainMin=$gainMin," + + " gainMax=$gainMax, offset=$offset, offsetMin=$offsetMin," + + " offsetMax=$offsetMax, hasGuiderHead=$hasGuiderHead," + + " canPulseGuide=$canPulseGuide, pulseGuiding=$pulseGuiding)" +} diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/DeviceProtocolHandler.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/DeviceProtocolHandler.kt index 991c83e0a..67ca1450a 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/DeviceProtocolHandler.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/DeviceProtocolHandler.kt @@ -155,6 +155,8 @@ abstract class DeviceProtocolHandler : MessageSender, INDIProtocolParser { wheels.clear() focusers.clear() gps.clear() + guideOutputs.clear() + thermometers.clear() notRegisteredDevices.clear() messageQueueCounter.clear() diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt index 7e740746d..a1d0675f2 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt @@ -11,9 +11,12 @@ internal open class FilterWheelDevice( name: String, ) : INDIDevice(handler, name), FilterWheel { - override var count = 0 - override var position = -1 - override var moving = false + @Volatile final override var count = 0 + private set + @Volatile final override var position = -1 + private set + @Volatile final override var moving = false + private set override fun handleMessage(message: INDIProtocol) { when (message) { diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt index a8da9ff08..7809c7eb2 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt @@ -11,19 +11,31 @@ internal open class FocuserDevice( name: String, ) : INDIDevice(handler, name), Focuser { - override var moving = false - override var position = 0 - override var canAbsoluteMove = false - override var canRelativeMove = false - override var canAbort = false - override var canReverse = false - override var reverse = false - override var canSync = false - override var hasBacklash = false - override var maxPosition = 0 - - override var hasThermometer = false - override var temperature = 0.0 + @Volatile final override var moving = false + private set + @Volatile final override var position = 0 + private set + @Volatile final override var canAbsoluteMove = false + private set + @Volatile final override var canRelativeMove = false + private set + @Volatile final override var canAbort = false + private set + @Volatile final override var canReverse = false + private set + @Volatile final override var reverse = false + private set + @Volatile final override var canSync = false + private set + @Volatile final override var hasBacklash = false + private set + @Volatile final override var maxPosition = 0 + private set + + @Volatile final override var hasThermometer = false + private set + @Volatile final override var temperature = 0.0 + private set override fun handleMessage(message: INDIProtocol) { when (message) { diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt index 6285a46ff..c57dfdd0f 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt @@ -11,16 +11,21 @@ import nebulosa.math.m import java.time.OffsetDateTime import java.time.ZoneOffset -internal class GPSDevice( +internal open class GPSDevice( handler: DeviceProtocolHandler, name: String, ) : INDIDevice(handler, name), GPS { - override val hasGPS = true - override var longitude = 0.0 - override var latitude = 0.0 - override var elevation = 0.0 - override var dateTime = OffsetDateTime.MIN!! + @Volatile final override var hasGPS = true + private set + @Volatile final override var longitude = 0.0 + private set + @Volatile final override var latitude = 0.0 + private set + @Volatile final override var elevation = 0.0 + private set + @Volatile final override var dateTime = OffsetDateTime.MIN!! + private set override fun handleMessage(message: INDIProtocol) { when (message) { diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt index 0853b2c71..3d62eb665 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt @@ -21,7 +21,7 @@ internal abstract class INDIDevice( override val properties = linkedMapOf>() override val messages = LinkedList() - override var connected = false + @Volatile override var connected = false protected set override fun sendMessageToServer(message: INDIProtocol) { diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt index 90775d176..8fff75291 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt @@ -1,8 +1,8 @@ package nebulosa.indi.client.device.camera import nebulosa.imaging.algorithms.transformation.CfaPattern -import nebulosa.indi.client.device.INDIDevice import nebulosa.indi.client.device.DeviceProtocolHandler +import nebulosa.indi.client.device.INDIDevice import nebulosa.indi.device.camera.* import nebulosa.indi.device.guide.GuideOutputPulsingChanged import nebulosa.indi.protocol.* @@ -15,56 +15,104 @@ internal open class CameraDevice( name: String, ) : INDIDevice(handler, name), Camera { - override var exposuring = false - override var hasCoolerControl = false - override var coolerPower = 0.0 - override var cooler = false - override var hasDewHeater = false - override var dewHeater = false - override var frameFormats = emptyList() - override var canAbort = false - override var cfaOffsetX = 0 - override var cfaOffsetY = 0 - override var cfaType = CfaPattern.RGGB - override var exposureMin: Duration = Duration.ZERO - override var exposureMax: Duration = Duration.ZERO - override var exposureState = PropertyState.IDLE - override var exposureTime: Duration = Duration.ZERO - override var hasCooler = false - override var canSetTemperature = false - override var canSubFrame = false - override var x = 0 - override var minX = 0 - override var maxX = 0 - override var y = 0 - override var minY = 0 - override var maxY = 0 - override var width = 0 - override var minWidth = 0 - override var maxWidth = 0 - override var height = 0 - override var minHeight = 0 - override var maxHeight = 0 - override var canBin = false - override var maxBinX = 1 - override var maxBinY = 1 - override var binX = 1 - override var binY = 1 - override var gain = 0 - override var gainMin = 0 - override var gainMax = 0 - override var offset = 0 - override var offsetMin = 0 - override var offsetMax = 0 - override var hasGuiderHead = false // TODO: Handle guider head. - override var pixelSizeX = 0.0 - override var pixelSizeY = 0.0 - - override var hasThermometer = false - override var temperature = 0.0 - - override var canPulseGuide = false - override var pulseGuiding = false + @Volatile final override var exposuring = false + private set + @Volatile final override var hasCoolerControl = false + private set + @Volatile final override var coolerPower = 0.0 + private set + @Volatile final override var cooler = false + private set + @Volatile final override var hasDewHeater = false + private set + @Volatile final override var dewHeater = false + private set + @Volatile final override var frameFormats = emptyList() + private set + @Volatile final override var canAbort = false + private set + @Volatile final override var cfaOffsetX = 0 + private set + @Volatile final override var cfaOffsetY = 0 + private set + @Volatile final override var cfaType = CfaPattern.RGGB + private set + @Volatile final override var exposureMin: Duration = Duration.ZERO + private set + @Volatile final override var exposureMax: Duration = Duration.ZERO + private set + @Volatile final override var exposureState = PropertyState.IDLE + private set + @Volatile final override var exposureTime: Duration = Duration.ZERO + private set + @Volatile final override var hasCooler = false + private set + @Volatile final override var canSetTemperature = false + private set + @Volatile final override var canSubFrame = false + private set + @Volatile final override var x = 0 + private set + @Volatile final override var minX = 0 + private set + @Volatile final override var maxX = 0 + private set + @Volatile final override var y = 0 + private set + @Volatile final override var minY = 0 + private set + @Volatile final override var maxY = 0 + private set + @Volatile final override var width = 0 + private set + @Volatile final override var minWidth = 0 + private set + @Volatile final override var maxWidth = 0 + private set + @Volatile final override var height = 0 + private set + @Volatile final override var minHeight = 0 + private set + @Volatile final override var maxHeight = 0 + private set + @Volatile final override var canBin = false + private set + @Volatile final override var maxBinX = 1 + private set + @Volatile final override var maxBinY = 1 + private set + @Volatile final override var binX = 1 + private set + @Volatile final override var binY = 1 + private set + @Volatile final override var gain = 0 + private set + @Volatile final override var gainMin = 0 + private set + @Volatile final override var gainMax = 0 + private set + @Volatile final override var offset = 0 + private set + @Volatile final override var offsetMin = 0 + private set + @Volatile final override var offsetMax = 0 + private set + @Volatile final override var hasGuiderHead = false // TODO: Handle guider head. + private set + @Volatile final override var pixelSizeX = 0.0 + private set + @Volatile final override var pixelSizeY = 0.0 + private set + + @Volatile final override var hasThermometer = false + private set + @Volatile final override var temperature = 0.0 + private set + + @Volatile final override var canPulseGuide = false + private set + @Volatile final override var pulseGuiding = false + private set override fun handleMessage(message: INDIProtocol) { when (message) { @@ -138,9 +186,7 @@ internal open class CameraDevice( handler.fireOnEventReceived(CameraExposuringChanged(this)) } - if (exposureState == PropertyState.IDLE - && (prevExposureState == PropertyState.BUSY || exposuring) - ) { + if (exposureState == PropertyState.IDLE && (prevExposureState == PropertyState.BUSY || exposuring)) { handler.fireOnEventReceived(CameraExposureAborted(this)) } else if (exposureState == PropertyState.OK && prevExposureState == PropertyState.BUSY) { handler.fireOnEventReceived(CameraExposureFinished(this)) diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt index c5021d133..442adcde0 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt @@ -1,7 +1,7 @@ package nebulosa.indi.client.device.mount -import nebulosa.indi.client.device.INDIDevice import nebulosa.indi.client.device.DeviceProtocolHandler +import nebulosa.indi.client.device.INDIDevice import nebulosa.indi.device.firstOnSwitch import nebulosa.indi.device.firstOnSwitchOrNull import nebulosa.indi.device.gps.GPS @@ -20,34 +20,60 @@ internal open class MountDevice( name: String, ) : INDIDevice(handler, name), Mount { - override var slewing = false - override var tracking = false - override var parking = false - override var parked = false - override var canAbort = false - override var canSync = false - override var canGoTo = false - override var canPark = false - override var canHome = false - override var slewRates = emptyList() - override var slewRate: SlewRate? = null - override var mountType = MountType.EQ_GEM // TODO: Ver os telescópios possui tipos. - override var trackModes = emptyList() - override var trackMode = TrackMode.SIDEREAL - override var pierSide = PierSide.NEITHER - override var guideRateWE = 0.0 // TODO: Tratar para cada driver. iOptronV3 tem RA/DE. LX200 tem 1.0x, 0.8x, 0.6x, 0.4x. - override var guideRateNS = 0.0 - override var rightAscension = 0.0 - override var declination = 0.0 - - override var canPulseGuide = false - override var pulseGuiding = false - - override var hasGPS = false - override var longitude = 0.0 - override var latitude = 0.0 - override var elevation = 0.0 - override var dateTime = OffsetDateTime.now()!! + @Volatile final override var slewing = false + private set + @Volatile final override var tracking = false + private set + @Volatile final override var parking = false + private set + @Volatile final override var parked = false + private set + @Volatile final override var canAbort = false + private set + @Volatile final override var canSync = false + private set + @Volatile final override var canGoTo = false + private set + @Volatile final override var canPark = false + private set + @Volatile final override var canHome = false + protected set + @Volatile final override var slewRates = emptyList() + private set + @Volatile final override var slewRate: SlewRate? = null + private set + @Volatile final override var mountType = MountType.EQ_GEM // TODO: Ver os telescópios possui tipos. + private set + @Volatile final override var trackModes = emptyList() + private set + @Volatile final override var trackMode = TrackMode.SIDEREAL + private set + @Volatile final override var pierSide = PierSide.NEITHER + private set + @Volatile final override var guideRateWE = 0.0 // TODO: Tratar para cada driver. iOptronV3 tem RA/DE. LX200 tem 1.0x, 0.8x, 0.6x, 0.4x. + private set + @Volatile final override var guideRateNS = 0.0 + private set + @Volatile final override var rightAscension = 0.0 + private set + @Volatile final override var declination = 0.0 + private set + + @Volatile final override var canPulseGuide = false + private set + @Volatile final override var pulseGuiding = false + private set + + @Volatile final override var hasGPS = false + private set + @Volatile final override var longitude = 0.0 + private set + @Volatile final override var latitude = 0.0 + private set + @Volatile final override var elevation = 0.0 + private set + @Volatile final override var dateTime = OffsetDateTime.now()!! + private set override fun handleMessage(message: INDIProtocol) { when (message) { diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceHub.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceHub.kt index a9ab84e87..4cb4a1aa8 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceHub.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceHub.kt @@ -7,8 +7,13 @@ import nebulosa.indi.device.gps.GPS import nebulosa.indi.device.guide.GuideOutput import nebulosa.indi.device.mount.Mount import nebulosa.indi.device.thermometer.Thermometer +import java.io.Closeable -interface DeviceHub { +interface DeviceHub : Closeable { + + fun registerDeviceEventHandler(handler: DeviceEventHandler) + + fun unregisterDeviceEventHandler(handler: DeviceEventHandler) fun cameras(): List From 336eb25080820c559caf47f18b7e09dde6f2697f Mon Sep 17 00:00:00 2001 From: tiagohm Date: Tue, 13 Feb 2024 16:04:51 -0300 Subject: [PATCH 16/87] [api]: Add support to ASCOM Alpaca API --- .../beans/configurations/BeanConfiguration.kt | 11 +- .../api/connection/ConnectionService.kt | 46 +++---- .../nebulosa/api/indi/INDIController.kt | 2 +- .../alpaca/indi/client/AlpacaClient.kt | 4 +- .../alpaca/indi/devices/ASCOMDevice.kt | 50 ++++---- .../indi/devices/cameras/ASCOMCamera.kt | 113 ++++++++++-------- .../kotlin/nebulosa/indi/client/INDIClient.kt | 4 +- .../nebulosa/indi/client/device/INDIDevice.kt | 16 ++- .../indi/client/device/camera/CameraDevice.kt | 9 +- .../{DeviceHub.kt => INDIDeviceProvider.kt} | 2 +- .../nebulosa/indi/device/camera/Camera.kt | 2 + 11 files changed, 155 insertions(+), 104 deletions(-) rename nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/{DeviceHub.kt => INDIDeviceProvider.kt} (96%) 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 c1bbb08b8..430272fab 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt @@ -94,7 +94,7 @@ class BeanConfiguration { fun cache(cachePath: Path) = Cache(cachePath.toFile(), MAX_CACHE_SIZE) @Bean - fun httpLogger() = HttpLoggingInterceptor.Logger { OKHTTP_LOGGER.debug(it) } + fun httpLogger() = HttpLoggingInterceptor.Logger { OKHTTP_LOGGER.info(it) } @Bean fun httpClient(connectionPool: ConnectionPool, cache: Cache, httpLogger: HttpLoggingInterceptor.Logger) = OkHttpClient.Builder() @@ -107,6 +107,15 @@ class BeanConfiguration { .callTimeout(60L, TimeUnit.SECONDS) .build() + @Bean + fun alpacaHttpClient(connectionPool: ConnectionPool) = OkHttpClient.Builder() + .connectionPool(connectionPool) + .readTimeout(60L, TimeUnit.SECONDS) + .writeTimeout(60L, TimeUnit.SECONDS) + .connectTimeout(60L, TimeUnit.SECONDS) + .callTimeout(60L, TimeUnit.SECONDS) + .build() + @Bean fun horizonsService(httpClient: OkHttpClient) = HorizonsService(httpClient = httpClient) diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt index 27fc12dcc..670dae012 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt @@ -3,7 +3,7 @@ package nebulosa.api.connection import nebulosa.alpaca.indi.client.AlpacaClient import nebulosa.indi.client.INDIClient import nebulosa.indi.device.Device -import nebulosa.indi.device.DeviceHub +import nebulosa.indi.device.INDIDeviceProvider import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.filterwheel.FilterWheel import nebulosa.indi.device.focuser.Focuser @@ -23,13 +23,13 @@ import java.io.Closeable class ConnectionService( private val eventBus: EventBus, private val connectionEventHandler: ConnectionEventHandler, - private val httpClient: OkHttpClient, + private val alpacaHttpClient: OkHttpClient, ) : Closeable { - @Volatile private var deviceHub: DeviceHub? = null + @Volatile private var provider: INDIDeviceProvider? = null fun connectionStatus(): Boolean { - return deviceHub != null + return provider != null } @Synchronized @@ -37,7 +37,7 @@ class ConnectionService( try { disconnect() - deviceHub = when (type) { + provider = when (type) { ConnectionType.INDI -> { val client = INDIClient(host, port) client.registerDeviceEventHandler(eventBus::post) @@ -46,7 +46,7 @@ class ConnectionService( client } else -> { - val client = AlpacaClient(host, port, httpClient) + val client = AlpacaClient(host, port, alpacaHttpClient) client.registerDeviceEventHandler(eventBus::post) client.registerDeviceEventHandler(connectionEventHandler) client.discovery() @@ -62,8 +62,8 @@ class ConnectionService( @Synchronized fun disconnect() { - deviceHub?.close() - deviceHub = null + provider?.close() + provider = null } override fun close() { @@ -71,59 +71,59 @@ class ConnectionService( } fun cameras(): List { - return deviceHub?.cameras() ?: emptyList() + return provider?.cameras() ?: emptyList() } fun mounts(): List { - return deviceHub?.mounts() ?: emptyList() + return provider?.mounts() ?: emptyList() } fun focusers(): List { - return deviceHub?.focusers() ?: emptyList() + return provider?.focusers() ?: emptyList() } fun wheels(): List { - return deviceHub?.wheels() ?: emptyList() + return provider?.wheels() ?: emptyList() } fun gps(): List { - return deviceHub?.gps() ?: emptyList() + return provider?.gps() ?: emptyList() } fun guideOutputs(): List { - return deviceHub?.guideOutputs() ?: emptyList() + return provider?.guideOutputs() ?: emptyList() } fun thermometers(): List { - return deviceHub?.thermometers() ?: emptyList() + return provider?.thermometers() ?: emptyList() } fun camera(name: String): Camera? { - return deviceHub?.camera(name) + return provider?.camera(name) } fun mount(name: String): Mount? { - return deviceHub?.mount(name) + return provider?.mount(name) } fun focuser(name: String): Focuser? { - return deviceHub?.focuser(name) + return provider?.focuser(name) } fun wheel(name: String): FilterWheel? { - return deviceHub?.wheel(name) + return provider?.wheel(name) } fun gps(name: String): GPS? { - return deviceHub?.gps(name) + return provider?.gps(name) } fun guideOutput(name: String): GuideOutput? { - return deviceHub?.guideOutput(name) + return provider?.guideOutput(name) } fun thermometer(name: String): Thermometer? { - return deviceHub?.thermometer(name) + return provider?.thermometer(name) } fun device(name: String): Device? { @@ -132,6 +132,8 @@ class ConnectionService( ?: focuser(name) ?: wheel(name) ?: guideOutput(name) + ?: gps(name) + ?: thermometer(name) } companion object { diff --git a/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt b/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt index 3a8412201..2b3296113 100644 --- a/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt +++ b/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt @@ -28,7 +28,7 @@ class INDIController( @GetMapping("{device}/log") fun log(@DeviceOrEntityParam device: Device): List { - return device.messages + return synchronized(device.messages) { device.messages } } @GetMapping("log") diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt index a042a8b01..ee69c2a17 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt @@ -6,7 +6,7 @@ import nebulosa.alpaca.indi.devices.ASCOMDevice import nebulosa.alpaca.indi.devices.cameras.ASCOMCamera import nebulosa.indi.device.DeviceEvent import nebulosa.indi.device.DeviceEventHandler -import nebulosa.indi.device.DeviceHub +import nebulosa.indi.device.INDIDeviceProvider import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.CameraAttached import nebulosa.indi.device.camera.CameraDetached @@ -24,7 +24,7 @@ class AlpacaClient( host: String, port: Int, httpClient: OkHttpClient? = null, -) : DeviceHub { +) : INDIDeviceProvider { private val service = AlpacaService("http://$host:$port/", httpClient) private val handlers = LinkedHashSet() diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt index 14c5fa661..288301bbf 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt @@ -36,15 +36,15 @@ abstract class ASCOMDevice : Device { @Volatile private var refresher: Refresher? = null override fun connect() { - executeRequest(service.connect(device.number, true)) + service.connect(device.number, true).doRequest() } override fun disconnect() { - executeRequest(service.connect(device.number, false)) + service.connect(device.number, false).doRequest() } open fun refresh(elapsedTimeInSeconds: Long) { - executeRequest(service.isConnected(device.number)) { processConnected(it.value) } + service.isConnected(device.number).doRequest { processConnected(it.value) } } open fun reset() { @@ -61,17 +61,24 @@ abstract class ASCOMDevice : Device { protected abstract fun onDisconnected() private fun addMessageAndFireEvent(text: String) { - messages.addFirst(text) - client.fireOnEventReceived(DeviceMessageReceived(this, text)) + synchronized(messages) { + messages.addFirst(text) + + client.fireOnEventReceived(DeviceMessageReceived(this, text)) + + if (messages.size > 100) { + messages.removeLast() + } + } } - protected fun > executeRequest(call: Call): T? { + protected fun > Call.doRequest(): T? { try { - val response = call.execute().body() + val response = execute().body() - if (response == null) { + return if (response == null) { LOG.warn("response has no body. device={}", name) - return null + null } else if (response.errorNumber != 0) { val message = response.errorMessage @@ -79,10 +86,11 @@ abstract class ASCOMDevice : Device { addMessageAndFireEvent("[%s]: %s".format(LocalDateTime.now(), message)) } - LOG.warn("unsuccessful response. device={}, code={}, message={}", name, response.errorNumber, response.errorMessage) - return null + // LOG.warn("unsuccessful response. device={}, code={}, message={}", name, response.errorNumber, response.errorMessage) + + null } else { - return response + response } } catch (e: HttpException) { LOG.error("unexpected response. device=$name", e) @@ -93,8 +101,8 @@ abstract class ASCOMDevice : Device { return null } - protected inline fun > executeRequest(call: Call, action: (T) -> Unit): Boolean { - return executeRequest(call)?.also(action) != null + protected inline fun > Call.doRequest(action: (T) -> Unit): Boolean { + return doRequest()?.also(action) != null } protected fun processConnected(value: Boolean) { @@ -128,13 +136,15 @@ abstract class ASCOMDevice : Device { override fun run() { stopwatch.start() - val startTime = System.currentTimeMillis() - refresh(stopwatch.elapsedSeconds) - val endTime = System.currentTimeMillis() - val delayTime = 1000L - (endTime - startTime) + while (true) { + val startTime = System.currentTimeMillis() + refresh(stopwatch.elapsedSeconds) + val endTime = System.currentTimeMillis() + val delayTime = 2000L - (endTime - startTime) - if (delayTime > 1L) { - sleep(delayTime) + if (delayTime > 1L) { + sleep(delayTime) + } } } } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt index 531ab6ee2..064009d7b 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt @@ -9,6 +9,7 @@ import nebulosa.alpaca.indi.devices.ASCOMDevice import nebulosa.imaging.algorithms.transformation.CfaPattern import nebulosa.indi.device.Device import nebulosa.indi.device.camera.* +import nebulosa.indi.device.camera.Camera.Companion.NANO_SECONDS import nebulosa.indi.device.guide.GuideOutputPulsingChanged import nebulosa.indi.protocol.INDIProtocol import nebulosa.indi.protocol.PropertyState @@ -129,7 +130,7 @@ data class ASCOMCamera( } override fun cooler(enabled: Boolean) { - executeRequest(service.cooler(device.number, enabled)) + service.cooler(device.number, enabled).doRequest() } override fun dewHeater(enabled: Boolean) { @@ -137,11 +138,15 @@ data class ASCOMCamera( } override fun temperature(value: Double) { - executeRequest(service.setpointCCDTemperature(device.number, value)) + service.setpointCCDTemperature(device.number, value).doRequest() } override fun frameFormat(format: String?) { - // TODO + val index = frameFormats.indexOf(format) + + if (index >= 0) { + service.readoutMode(device.number, index).doRequest() + } } override fun frameType(type: FrameType) { @@ -149,38 +154,38 @@ data class ASCOMCamera( } override fun frame(x: Int, y: Int, width: Int, height: Int) { - executeRequest(service.startX(device.number, x)) ?: return - executeRequest(service.startY(device.number, y)) ?: return - executeRequest(service.numX(device.number, width)) ?: return - executeRequest(service.numY(device.number, height)) + service.startX(device.number, x).doRequest() ?: return + service.startY(device.number, y).doRequest() ?: return + service.numX(device.number, width).doRequest() ?: return + service.numY(device.number, height).doRequest() } override fun bin(x: Int, y: Int) { - executeRequest(service.binX(device.number, x)) ?: return - executeRequest(service.binY(device.number, y)) + service.binX(device.number, x).doRequest() ?: return + service.binY(device.number, y).doRequest() } override fun gain(value: Int) { - executeRequest(service.gain(device.number, value)) + service.gain(device.number, value).doRequest() } override fun offset(value: Int) { - executeRequest(service.offset(device.number, value)) + service.offset(device.number, value).doRequest() } override fun startCapture(exposureTime: Duration) { this.exposureTime = exposureTime - executeRequest(service.startExposure(device.number, exposureTime.toNanos() / 1000000000.0, frameType == FrameType.DARK)) + service.startExposure(device.number, exposureTime.toNanos() / NANO_SECONDS, frameType == FrameType.DARK).doRequest() } override fun abortCapture() { - executeRequest(service.abortExposure(device.number)) + service.abortExposure(device.number).doRequest() } private fun pulseGuide(direction: PulseGuideDirection, duration: Duration) { val durationInMilliseconds = duration.toMillis() - executeRequest(service.pulseGuide(device.number, direction, durationInMilliseconds)) ?: return + service.pulseGuide(device.number, direction, durationInMilliseconds).doRequest() ?: return if (durationInMilliseconds > 0) { pulseGuiding = true @@ -221,6 +226,7 @@ data class ASCOMCamera( processCapabilities() processPixelSize() processCfaOffset() + processReadoutModes() } override fun onDisconnected() { @@ -285,15 +291,18 @@ data class ASCOMCamera( reset() } + @Synchronized override fun refresh(elapsedTimeInSeconds: Long) { super.refresh(elapsedTimeInSeconds) - executeRequest(service.cameraState(device.number)) { processCameraState(it.value) } + if (connected) { + service.cameraState(device.number).doRequest { processCameraState(it.value) } - processBin() - processGain() - processOffset() - processCooler() + processBin() + processGain() + processOffset() + processCooler() + } } private fun processCameraState(value: CameraState) { @@ -346,7 +355,7 @@ data class ASCOMCamera( if (prevExposureState != exposureState) client.fireOnEventReceived(CameraExposureStateChanged(this, prevExposureState)) if (exposuring) { - executeRequest(service.percentCompleted(device.number)) { + service.percentCompleted(device.number).doRequest { } } @@ -362,8 +371,8 @@ data class ASCOMCamera( } private fun processBin() { - executeRequest(service.binX(device.number)) { x -> - executeRequest(service.binY(device.number)) { y -> + service.binX(device.number).doRequest { x -> + service.binY(device.number).doRequest { y -> if (x.value != binX || y.value != binY) { binX = x.value binY = y.value @@ -375,8 +384,8 @@ data class ASCOMCamera( } private fun processGainMinMax() { - executeRequest(service.gainMin(device.number)) { min -> - executeRequest(service.gainMax(device.number)) { max -> + service.gainMin(device.number).doRequest { min -> + service.gainMax(device.number).doRequest { max -> gainMin = min.value gainMax = max.value gain = max(gainMin, min(gain, gainMax)) @@ -387,7 +396,7 @@ data class ASCOMCamera( } private fun processGain() { - executeRequest(service.gain(device.number)) { + service.gain(device.number).doRequest { if (it.value != gain) { gain = it.value @@ -397,8 +406,8 @@ data class ASCOMCamera( } private fun processOffsetMinMax() { - executeRequest(service.offsetMin(device.number)) { min -> - executeRequest(service.offsetMax(device.number)) { max -> + service.offsetMin(device.number).doRequest { min -> + service.offsetMax(device.number).doRequest { max -> offsetMin = min.value offsetMax = max.value offset = max(offsetMin, min(offset, offsetMax)) @@ -409,7 +418,7 @@ data class ASCOMCamera( } private fun processOffset() { - executeRequest(service.offset(device.number)) { + service.offset(device.number).doRequest { if (it.value != offset) { offset = it.value @@ -419,8 +428,8 @@ data class ASCOMCamera( } private fun processFrameMinMax() { - executeRequest(service.x(device.number)) { w -> - executeRequest(service.y(device.number)) { h -> + service.x(device.number).doRequest { w -> + service.y(device.number).doRequest { h -> width = w.value height = h.value minWidth = 0 @@ -442,10 +451,10 @@ data class ASCOMCamera( } private fun processFrame(): Boolean { - executeRequest(service.numX(device.number)) { w -> - executeRequest(service.numY(device.number)) { h -> - executeRequest(service.startX(device.number)) { x -> - executeRequest(service.startY(device.number)) { y -> + service.numX(device.number).doRequest { w -> + service.numY(device.number).doRequest { h -> + service.startX(device.number).doRequest { x -> + service.startY(device.number).doRequest { y -> if (w.value != width || h.value != height || x.value != this.x || y.value != this.y) { width = w.value height = h.value @@ -466,7 +475,7 @@ data class ASCOMCamera( private fun processCooler() { if (hasCoolerControl) { - executeRequest(service.coolerPower(device.number)) { + service.coolerPower(device.number).doRequest { if (coolerPower != it.value) { coolerPower = it.value @@ -476,7 +485,7 @@ data class ASCOMCamera( } if (hasCooler) { - executeRequest(service.isCoolerOn(device.number)) { + service.isCoolerOn(device.number).doRequest { if (cooler != it.value) { cooler = it.value @@ -487,8 +496,8 @@ data class ASCOMCamera( } private fun processPixelSize() { - executeRequest(service.pixelSizeX(device.number)) { x -> - executeRequest(service.pixelSizeY(device.number)) { y -> + service.pixelSizeX(device.number).doRequest { x -> + service.pixelSizeY(device.number).doRequest { y -> if (pixelSizeX != x.value || pixelSizeY != y.value) { pixelSizeX = x.value pixelSizeY = y.value @@ -500,8 +509,8 @@ data class ASCOMCamera( } private fun processCfaOffset() { - executeRequest(service.bayerOffsetX(device.number)) { x -> - executeRequest(service.bayerOffsetY(device.number)) { y -> + service.bayerOffsetX(device.number).doRequest { x -> + service.bayerOffsetY(device.number).doRequest { y -> if (cfaOffsetX != x.value || cfaOffsetY != y.value) { cfaOffsetX = x.value cfaOffsetY = y.value @@ -512,11 +521,19 @@ data class ASCOMCamera( } } + private fun processReadoutModes() { + service.readoutModes(device.number).doRequest { + frameFormats = it.value.toList() + + client.fireOnEventReceived(CameraFrameFormatsChanged(this)) + } + } + private fun processExposureMinMax() { - executeRequest(service.exposureMin(device.number)) { min -> - executeRequest(service.exposureMax(device.number)) { max -> - exposureMin = Duration.ofNanos((min.value * 1000000000.0).toLong()) - exposureMax = Duration.ofNanos((max.value * 1000000000.0).toLong()) + service.exposureMin(device.number).doRequest { min -> + service.exposureMax(device.number).doRequest { max -> + exposureMin = Duration.ofNanos((min.value * NANO_SECONDS).toLong()) + exposureMax = Duration.ofNanos((max.value * NANO_SECONDS).toLong()) client.fireOnEventReceived(CameraExposureMinMaxChanged(this)) } @@ -524,7 +541,7 @@ data class ASCOMCamera( } private fun processCapabilities() { - executeRequest(service.canAbortExposure(device.number)) { + service.canAbortExposure(device.number).doRequest { if (it.value != canAbort) { canAbort = it.value @@ -532,7 +549,7 @@ data class ASCOMCamera( } } - executeRequest(service.canCoolerPower(device.number)) { + service.canCoolerPower(device.number).doRequest { if (it.value != hasCoolerControl) { hasCoolerControl = it.value @@ -540,7 +557,7 @@ data class ASCOMCamera( } } - executeRequest(service.canPulseGuide(device.number)) { + service.canPulseGuide(device.number).doRequest { if (it.value != canPulseGuide) { canPulseGuide = it.value @@ -550,7 +567,7 @@ data class ASCOMCamera( } } - executeRequest(service.canSetCCDTemperature(device.number)) { + service.canSetCCDTemperature(device.number).doRequest { if (it.value != canSetTemperature) { canSetTemperature = it.value hasCooler = canSetTemperature diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt index ec47e0f1e..0b88845a6 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt @@ -3,7 +3,7 @@ package nebulosa.indi.client import nebulosa.indi.client.connection.INDIProccessConnection import nebulosa.indi.client.connection.INDISocketConnection import nebulosa.indi.client.device.DeviceProtocolHandler -import nebulosa.indi.device.DeviceHub +import nebulosa.indi.device.INDIDeviceProvider import nebulosa.indi.device.MessageSender import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.filterwheel.FilterWheel @@ -18,7 +18,7 @@ import nebulosa.indi.protocol.io.INDIConnection import nebulosa.log.debug import nebulosa.log.loggerFor -class INDIClient(private val connection: INDIConnection) : DeviceProtocolHandler(), MessageSender, DeviceHub { +class INDIClient(private val connection: INDIConnection) : DeviceProtocolHandler(), MessageSender, INDIDeviceProvider { constructor( host: String, diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt index 3d62eb665..14e382451 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt @@ -28,6 +28,18 @@ internal abstract class INDIDevice( handler.sendMessageToServer(message) } + private fun addMessageAndFireEvent(text: String) { + synchronized(messages) { + messages.addFirst(text) + + handler.fireOnEventReceived(DeviceMessageReceived(this, text)) + + if (messages.size > 100) { + messages.removeLast() + } + } + } + override fun handleMessage(message: INDIProtocol) { when (message) { is SwitchVector<*> -> { @@ -58,9 +70,7 @@ internal abstract class INDIDevice( handler.fireOnEventReceived(DevicePropertyDeleted(property)) } is Message -> { - val text = "[%s]: %s".format(message.timestamp, message.message) - messages.addFirst(text) - handler.fireOnEventReceived(DeviceMessageReceived(this, text)) + addMessageAndFireEvent("[%s]: %s".format(message.timestamp, message.message)) } else -> Unit } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt index 8fff75291..537fe84f3 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt @@ -4,6 +4,7 @@ import nebulosa.imaging.algorithms.transformation.CfaPattern import nebulosa.indi.client.device.DeviceProtocolHandler import nebulosa.indi.client.device.INDIDevice import nebulosa.indi.device.camera.* +import nebulosa.indi.device.camera.Camera.Companion.NANO_SECONDS import nebulosa.indi.device.guide.GuideOutputPulsingChanged import nebulosa.indi.protocol.* import nebulosa.io.Base64InputStream @@ -165,8 +166,8 @@ internal open class CameraDevice( val element = message["CCD_EXPOSURE_VALUE"]!! if (element is DefNumber) { - exposureMin = Duration.ofNanos((element.min * 1000000000.0).toLong()) - exposureMax = Duration.ofNanos((element.max * 1000000000.0).toLong()) + exposureMin = Duration.ofNanos((element.min * NANO_SECONDS).toLong()) + exposureMax = Duration.ofNanos((element.max * NANO_SECONDS).toLong()) handler.fireOnEventReceived(CameraExposureMinMaxChanged(this)) } @@ -174,7 +175,7 @@ internal open class CameraDevice( exposureState = message.state if (exposureState == PropertyState.BUSY || exposureState == PropertyState.OK) { - exposureTime = Duration.ofNanos((element.value * 1000000000.0).toLong()) + exposureTime = Duration.ofNanos((element.value * NANO_SECONDS).toLong()) handler.fireOnEventReceived(CameraExposureProgressChanged(this)) } @@ -349,7 +350,7 @@ internal open class CameraDevice( override fun offset(value: Int) = Unit override fun startCapture(exposureTime: Duration) { - val exposureInSeconds = exposureTime.toNanos() / 1000000000.0 + val exposureInSeconds = exposureTime.toNanos() / NANO_SECONDS sendNewNumber("CCD_EXPOSURE", "CCD_EXPOSURE_VALUE" to exposureInSeconds) } diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceHub.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/INDIDeviceProvider.kt similarity index 96% rename from nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceHub.kt rename to nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/INDIDeviceProvider.kt index 4cb4a1aa8..c7e1310dc 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceHub.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/INDIDeviceProvider.kt @@ -9,7 +9,7 @@ import nebulosa.indi.device.mount.Mount import nebulosa.indi.device.thermometer.Thermometer import java.io.Closeable -interface DeviceHub : Closeable { +interface INDIDeviceProvider : Closeable { fun registerDeviceEventHandler(handler: DeviceEventHandler) diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/Camera.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/Camera.kt index d1de55e98..6ec7efae8 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/Camera.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/Camera.kt @@ -120,6 +120,8 @@ interface Camera : GuideOutput, Thermometer { companion object { + const val NANO_SECONDS = 1_000_000_000.0 + @JvmStatic val DRIVERS = setOf( "indi_altair_ccd", "indi_apogee_ccd", From f05d30504fd268f92e3e8aae84ae47605b50832e Mon Sep 17 00:00:00 2001 From: tiagohm Date: Tue, 13 Feb 2024 21:28:19 -0300 Subject: [PATCH 17/87] [api]: Add Melotte catalog to Simbad database generator --- .../test/kotlin/SimbadDatabaseGenerator.kt | 15 +- api/src/test/resources/melotte.csv | 247 ++++++++++++++++++ 2 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 api/src/test/resources/melotte.csv diff --git a/api/src/test/kotlin/SimbadDatabaseGenerator.kt b/api/src/test/kotlin/SimbadDatabaseGenerator.kt index 8f194f059..5c1584327 100644 --- a/api/src/test/kotlin/SimbadDatabaseGenerator.kt +++ b/api/src/test/kotlin/SimbadDatabaseGenerator.kt @@ -47,6 +47,12 @@ object SimbadDatabaseGenerator { .commentCharacter('#') .commentStrategy(CommentStrategy.SKIP) + @JvmStatic private val MELOTTE = resource("melotte.csv")!! + .use { stream -> + CSV_READER.ofCsvRecord(InputStreamReader(stream, Charsets.UTF_8)) + .associate { it.getField(1) to it.getField(0) } + } + @JvmStatic private val CALDWELL = resource("caldwell.csv")!! .use { stream -> CSV_READER.ofCsvRecord(InputStreamReader(stream, Charsets.UTF_8)) @@ -128,6 +134,9 @@ object SimbadDatabaseGenerator { } for (name in names) { + if (name in MELOTTE) { + moreNames.add("Mel ${MELOTTE[name]}") + } if (name in CALDWELL) { moreNames.add("C ${CALDWELL[name]}") } @@ -145,14 +154,14 @@ object SimbadDatabaseGenerator { name = buildString { var i = 0 - moreNames.forEach { + names.forEach { if (i > 0) append(SkyObject.NAME_SEPARATOR) append(it) i++ } - names.forEach { n -> - if (moreNames.none { it.equals(n, true) }) { + moreNames.forEach { n -> + if (names.none { it.equals(n, true) }) { if (i > 0) append(SkyObject.NAME_SEPARATOR) append(n) i++ diff --git a/api/src/test/resources/melotte.csv b/api/src/test/resources/melotte.csv new file mode 100644 index 000000000..54423f7c2 --- /dev/null +++ b/api/src/test/resources/melotte.csv @@ -0,0 +1,247 @@ +# Source: https://en.wikipedia.org/wiki/Melotte_catalogue +ID,NGC +1,NGC 104 +2,NGC 188 +3,NGC 288 +4,NGC 362 +5,NGC 371 +6,NGC 436 +7,NGC 457 +8,NGC 581 +9,NGC 654 +10,NGC 659 +11,NGC 663 +12,NGC 752 +13,NGC 869 +14,NGC 884 +15,IC 1805 +16,NGC 1027 +17,NGC 1039 +18,NGC 1245 +19,NGC 1261 +#20, +21,NGC 1342 +#22, +23,NGC 1528 +24,IC 361 +#25, +26,NGC 1647 +27,NGC 1664 +28,NGC 1746 +29,NGC 1807 +30,NGC 1851 +#31, +32,NGC 1857 +33,NGC 1893 +34,NGC 1904 +35,NGC 1907 +36,NGC 1912 +37,NGC 1960 +38,NGC 2099 +39,NGC 2126 +40,NGC 2158 +41,NGC 2168 +42,NGC 2192 +43,NGC 2194 +44,NGC 2204 +45,NGC 2215 +46,NGC 2243 +47,NGC 2244 +48,NGC 2259 +49,NGC 2264 +50,NGC 2266 +51,NGC 2281 +52,NGC 2287 +53,NGC 2298 +54,NGC 2301 +55,NGC 2304 +56,NGC 2309 +57,NGC 2314 +58,NGC 2323 +59,NGC 2324 +60,NGC 2335 +61,NGC 2345 +62,NGC 2353 +63,NGC 2355 +64,NGC 2360 +65,NGC 2362 +#66, +67,NGC 2421 +68,NGC 2422 +69,NGC 2420 +70,NGC 2423 +#71, +#72, +73,NGC 2432 +74,NGC 2439 +75,NGC 2437 +76,NGC 2447 +77,NGC 2455 +78,NGC 2477 +79,NGC 2489 +80,NGC 2506 +81,NGC 2509 +82,NGC 2516 +83,NGC 2539 +84,NGC 2547 +85,NGC 2548 +86,NGC 2567 +87,NGC 2627 +88,NGC 2632 +89,NGC 2635 +90,NGC 2658 +91,NGC 2659 +92,NGC 2660 +93,NGC 2670 +94,NGC 2682 +95,NGC 2808 +96,NGC 2818 +97,IC 2488 +98,NGC 3114 +99,NGC 3201 +100,NGC 3293 +#101, +102,IC 2602 +103,NGC 3532 +104,IC 2714 +#105, +106,NGC 3680 +107,NGC 3766 +108,NGC 3960 +109,NGC 4103 +110,NGC 4349 +#111, +112,NGC 4372 +113,NGC 4590 +114,NGC 4755 +115,NGC 4833 +116,NGC 4852 +117,NGC 5024 +118,NGC 5139 +119,NGC 5272 +120,NGC 5281 +121,NGC 5286 +122,NGC 5316 +123,NGC 5460 +124,NGC 5466 +125,NGC 5617 +126,NGC 5634 +127,NGC 5662 +128,NGC 5715 +129,IC 4499 +130,NGC 5822 +131,NGC 5823 +132,NGC 5897 +133,NGC 5904 +134,NGC 5927 +135,NGC 5946 +136,NGC 5986 +137,NGC 5999 +138,NGC 6005 +139,NGC 6025 +140,NGC 6067 +141,NGC 6087 +142,NGC 6093 +143,NGC 6101 +144,NGC 6121 +145,NGC 6124 +146,NGC 6134 +147,NGC 6144 +148,NGC 6171 +149,NGC 6192 +150,NGC 6205 +151,NGC 6218 +152,NGC 6222 +153,NGC 6231 +154,NGC 6235 +155,NGC 6242 +156,NGC 6253 +157,NGC 6254 +158,NGC 6259 +159,NGC 6266 +160,NGC 6273 +161,NGC 6281 +162,NGC 6284 +163,NGC 6287 +164,NGC 6293 +165,NGC 6304 +166,NGC 6318 +167,NGC 6333 +168,NGC 6341 +169,IC 4651 +170,NGC 6352 +171,NGC 6356 +172,NGC 6362 +173,NGC 6366 +174,NGC 6388 +175,NGC 6402 +176,NGC 6397 +177,NGC 6400 +178,NGC 6405 +179,IC 4665 +180,NGC 6441 +181,NGC 6451 +182,NGC 6469 +183,NGC 6475 +184,NGC 6494 +185,NGC 6496 +#186, +187,NGC 6520 +188,NGC 6531 +189,NGC 6535 +190,NGC 6539 +191,NGC 6541 +192,NGC 6544 +193,NGC 6553 +194,NGC 6558 +195,NGC 6569 +196,NGC 6584 +197,NGC 6603 +198,NGC 6611 +199,NGC 6624 +200,NGC 6626 +201,NGC 6633 +202,NGC 6637 +203,NGC 6642 +204,IC 4725 +205,NGC 6645 +206,NGC 6649 +207,NGC 6652 +208,NGC 6656 +209,NGC 6664 +210,IC 4756 +211,NGC 6681 +212,NGC 6694 +213,NGC 6705 +214,NGC 6709 +215,NGC 6712 +216,NGC 6715 +217,NGC 6723 +218,NGC 6752 +219,NGC 6760 +220,NGC 6779 +221,NGC 6809 +222,NGC 6811 +223,NGC 6819 +224,NGC 6830 +225,NGC 6834 +226,NGC 6838 +#227, +228,NGC 6864 +229,NGC 6866 +230,NGC 6934 +231,NGC 6939 +232,NGC 6940 +233,NGC 6981 +234,NGC 7078 +235,NGC 7089 +236,NGC 7092 +237,NGC 7099 +238,NGC 7209 +239,IC 1434 +240,NGC 7243 +241,NGC 7245 +242,NGC 7492 +243,NGC 7654 +244,NGC 7762 +245,NGC 7789 From b43d2de2ec3046bf9061035d558d245f8da9b560 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Tue, 13 Feb 2024 21:38:30 -0300 Subject: [PATCH 18/87] [api]: Bump Kotlin from 2.0.0-Beta3 to 2.0.0-Beta4 --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 2aaef195b..6fbb70f6e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,8 +5,8 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile buildscript { dependencies { - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.0-Beta3") - classpath("org.jetbrains.kotlin:kotlin-allopen:2.0.0-Beta3") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.0-Beta4") + classpath("org.jetbrains.kotlin:kotlin-allopen:2.0.0-Beta4") classpath("com.adarshr:gradle-test-logger-plugin:4.0.0") classpath("io.objectbox:objectbox-gradle-plugin:3.7.1") } From 194cfda247900f5a774e09ee10e8de8f3d4f88f1 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Tue, 13 Feb 2024 21:53:44 -0300 Subject: [PATCH 19/87] [api]: Add notification when Sky Atlas is updating --- .../nebulosa/api/atlas/SkyAtlasUpdateTask.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/api/src/main/kotlin/nebulosa/api/atlas/SkyAtlasUpdateTask.kt b/api/src/main/kotlin/nebulosa/api/atlas/SkyAtlasUpdateTask.kt index a7a55a9ab..8a1f178cf 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/SkyAtlasUpdateTask.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/SkyAtlasUpdateTask.kt @@ -1,5 +1,7 @@ package nebulosa.api.atlas +import nebulosa.api.messages.MessageService +import nebulosa.api.notifications.NotificationEvent import nebulosa.api.preferences.PreferenceService import nebulosa.log.loggerFor import okhttp3.OkHttpClient @@ -14,8 +16,21 @@ class SkyAtlasUpdateTask( private val httpClient: OkHttpClient, private val simbadEntityRepository: SimbadEntityRepository, private val preferenceService: PreferenceService, + private val messageService: MessageService, ) : Runnable { + data object UpdateStarted : NotificationEvent { + + override val type = "SKY_ATLAS.UPDATE_STARTED" + override val body = "Sky Atlas is being updated" + } + + data object UpdateFinished : NotificationEvent { + + override val type = "SKY_ATLAS.UPDATE_FINISHED" + override val body = "Sky Atlas was updated" + } + @Scheduled(fixedDelay = Long.MAX_VALUE, timeUnit = TimeUnit.SECONDS) override fun run() { var request = Request.Builder().get().url(VERSION_URL).build() @@ -27,6 +42,8 @@ class SkyAtlasUpdateTask( if (newestVersion != preferenceService.getText(VERSION_KEY) || simbadEntityRepository.isEmpty()) { LOG.info("Sky Atlas is out of date. Downloading...") + messageService.sendMessage(UpdateStarted) + var finished = false for (i in 0 until MAX_DATA_COUNT) { @@ -52,6 +69,9 @@ class SkyAtlasUpdateTask( } preferenceService.putText(VERSION_KEY, newestVersion) + messageService.sendMessage(UpdateFinished) + + LOG.info("Sky Atlas was updated. version={}, size={}", newestVersion, simbadEntityRepository.size) } else { LOG.info("Sky Atlas is up to date. version={}, size={}", newestVersion, simbadEntityRepository.size) } From 1597548513093a556cebe689781a114ebcdc5ebf Mon Sep 17 00:00:00 2001 From: tiagohm Date: Tue, 13 Feb 2024 22:00:40 -0300 Subject: [PATCH 20/87] [api]: Check ICRF center when subtracting --- nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt index 3981f03e0..92b9b6bfc 100644 --- a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt +++ b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt @@ -221,7 +221,10 @@ open class ICRF protected constructor( (target as Frame).rotationAt(time) } - operator fun minus(other: ICRF) = of(position - other.position, velocity - other.velocity, time, other.target, target) + operator fun minus(other: ICRF): ICRF { + require(center == other.center) { "you can only subtract two ICRF vectors if they both start at the same center" } + return of(position - other.position, velocity - other.velocity, time, other.target, target) + } operator fun unaryMinus() = of(-position, -velocity, time, target, center, javaClass) From 9c8f4caebabba5772e97ea024c9882665c3c2236 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Tue, 13 Feb 2024 22:12:54 -0300 Subject: [PATCH 21/87] [api]: Don't handle % in Sky Atlas search query --- .../kotlin/nebulosa/api/atlas/SimbadEntityRepository.kt | 6 ++---- nebulosa-nova/src/main/kotlin/nebulosa/nova/frame/ICRS.kt | 2 +- .../src/main/kotlin/nebulosa/nova/position/ICRF.kt | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/api/src/main/kotlin/nebulosa/api/atlas/SimbadEntityRepository.kt b/api/src/main/kotlin/nebulosa/api/atlas/SimbadEntityRepository.kt index eda339c8b..54040bec7 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/SimbadEntityRepository.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/SimbadEntityRepository.kt @@ -32,10 +32,8 @@ class SimbadEntityRepository(@Qualifier("simbadEntityBox") override val box: Box if (type != null) it.equal(SimbadEntity_.type, type.ordinal) if (constellation != null) it.equal(SimbadEntity_.constellation, constellation.ordinal) - if (name != null && name.trim().trim('%').isNotEmpty()) { - if (name.startsWith("%") == name.endsWith("%")) it.contains(SimbadEntity_.name, name.replace("%", ""), CASE_INSENSITIVE) - else if (name.endsWith("%")) it.startsWith(SimbadEntity_.name, name.replace("%", ""), CASE_INSENSITIVE) - else if (name.startsWith("%")) it.endsWith(SimbadEntity_.name, name.replace("%", ""), CASE_INSENSITIVE) + if (!name.isNullOrBlank()) { + it.contains(SimbadEntity_.name, name, CASE_INSENSITIVE) } if (useFilter) it.filter(object : QueryFilter { diff --git a/nebulosa-nova/src/main/kotlin/nebulosa/nova/frame/ICRS.kt b/nebulosa-nova/src/main/kotlin/nebulosa/nova/frame/ICRS.kt index 10bbe8c84..121a85d8c 100644 --- a/nebulosa-nova/src/main/kotlin/nebulosa/nova/frame/ICRS.kt +++ b/nebulosa-nova/src/main/kotlin/nebulosa/nova/frame/ICRS.kt @@ -3,7 +3,7 @@ package nebulosa.nova.frame import nebulosa.math.Matrix3D /** - * The International Coordinate Reference System (ICRS). + * The International Celestial Reference System (ICRS). * * The ICRS is a permanent reference frame which has replaced J2000, * with which its axes agree to within 0.02 arcseconds (closer than the diff --git a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt index 92b9b6bfc..1cbc928f5 100644 --- a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt +++ b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/ICRF.kt @@ -16,7 +16,7 @@ import kotlin.math.atan2 /** * An |xyz| position and velocity oriented to the ICRF axes. * - * The International Coordinate Reference Frame (ICRF) is a permanent + * The International Celestial Reference Frame (ICRF) is a permanent * reference frame that is the replacement for J2000. Their axes agree * to within 0.02 arcseconds. It also supersedes older equinox-based * systems like B1900 and B1950. From 067ee31444e48b807d5022454b3591622edb6caf Mon Sep 17 00:00:00 2001 From: tiagohm Date: Wed, 14 Feb 2024 09:23:51 -0300 Subject: [PATCH 22/87] [api]: Rename packages --- api/build.gradle.kts | 2 +- api/src/main/kotlin/nebulosa/api/README.md | 3 +-- .../nebulosa/api/atlas/SatelliteEntity.kt | 2 +- .../kotlin/nebulosa/api/atlas/SimbadEntity.kt | 6 +++--- .../beans/configurations/BeanConfiguration.kt | 2 +- .../ConstellationPropertyConverter.kt | 2 +- .../FrameTypePropertyConverter.kt | 2 +- .../SkyObjectTypePropertyConverter.kt | 2 +- .../converters/indi/DeviceDeserializer.kt | 2 +- .../api/calibration/CalibrationFrameEntity.kt | 4 ++-- .../indi => cameras}/CameraDeserializer.kt | 3 ++- .../api/{entities => database}/BoxEntity.kt | 2 +- .../indi => focusers}/FocuserDeserializer.kt | 3 ++- .../api/guiding/GuideOutputDeserializer.kt | 16 +++++++++++++++ .../api/guiding/GuideOutputSerializer.kt | 20 +++++++++++++++++++ .../indi/INDIPropertySerializer.kt | 2 +- .../indi/INDIPropertyVectorSerializer.kt | 2 +- .../nebulosa/api/locations/LocationEntity.kt | 2 +- .../indi => mounts}/MountDeserializer.kt | 3 ++- .../api/preferences/PreferenceEntity.kt | 2 +- .../api/repositories/BoxRepository.kt | 2 +- .../indi => wheels}/WheelDeserializer.kt | 3 ++- 22 files changed, 63 insertions(+), 24 deletions(-) rename api/src/main/kotlin/nebulosa/api/beans/converters/{ => database}/ConstellationPropertyConverter.kt (91%) rename api/src/main/kotlin/nebulosa/api/beans/converters/{ => database}/FrameTypePropertyConverter.kt (90%) rename api/src/main/kotlin/nebulosa/api/beans/converters/{ => database}/SkyObjectTypePropertyConverter.kt (91%) rename api/src/main/kotlin/nebulosa/api/{beans/converters/indi => cameras}/CameraDeserializer.kt (84%) rename api/src/main/kotlin/nebulosa/api/{entities => database}/BoxEntity.kt (58%) rename api/src/main/kotlin/nebulosa/api/{beans/converters/indi => focusers}/FocuserDeserializer.kt (84%) create mode 100644 api/src/main/kotlin/nebulosa/api/guiding/GuideOutputDeserializer.kt create mode 100644 api/src/main/kotlin/nebulosa/api/guiding/GuideOutputSerializer.kt rename api/src/main/kotlin/nebulosa/api/{beans/converters => }/indi/INDIPropertySerializer.kt (93%) rename api/src/main/kotlin/nebulosa/api/{beans/converters => }/indi/INDIPropertyVectorSerializer.kt (96%) rename api/src/main/kotlin/nebulosa/api/{beans/converters/indi => mounts}/MountDeserializer.kt (84%) rename api/src/main/kotlin/nebulosa/api/{beans/converters/indi => wheels}/WheelDeserializer.kt (85%) diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 2c1d60817..be9e07f65 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -62,6 +62,6 @@ tasks.withType { kapt { arguments { arg("objectbox.modelPath", "$projectDir/schemas/objectbox.json") - arg("objectbox.myObjectBoxPackage", "nebulosa.api.entities") + arg("objectbox.myObjectBoxPackage", "nebulosa.api.database") } } diff --git a/api/src/main/kotlin/nebulosa/api/README.md b/api/src/main/kotlin/nebulosa/api/README.md index f9c87e8ad..5b20496b5 100644 --- a/api/src/main/kotlin/nebulosa/api/README.md +++ b/api/src/main/kotlin/nebulosa/api/README.md @@ -185,8 +185,7 @@ URL: `localhost:{PORT}/ws` ```json5 { - "camera": {}, - "guideOutput": {}, + "id": "", "remainingTime": 0, "progress": 0.0, "direction": "EAST", diff --git a/api/src/main/kotlin/nebulosa/api/atlas/SatelliteEntity.kt b/api/src/main/kotlin/nebulosa/api/atlas/SatelliteEntity.kt index b4aba41e4..bdaaf93bf 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/SatelliteEntity.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/SatelliteEntity.kt @@ -2,7 +2,7 @@ package nebulosa.api.atlas import io.objectbox.annotation.Entity import io.objectbox.annotation.Id -import nebulosa.api.entities.BoxEntity +import nebulosa.api.database.BoxEntity @Entity data class SatelliteEntity( diff --git a/api/src/main/kotlin/nebulosa/api/atlas/SimbadEntity.kt b/api/src/main/kotlin/nebulosa/api/atlas/SimbadEntity.kt index 8051a9a4b..8fe232465 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/SimbadEntity.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/SimbadEntity.kt @@ -4,9 +4,9 @@ import com.fasterxml.jackson.annotation.JsonIgnore import io.objectbox.annotation.Convert import io.objectbox.annotation.Entity import io.objectbox.annotation.Id -import nebulosa.api.beans.converters.ConstellationPropertyConverter -import nebulosa.api.beans.converters.SkyObjectTypePropertyConverter -import nebulosa.api.entities.BoxEntity +import nebulosa.api.beans.converters.database.ConstellationPropertyConverter +import nebulosa.api.beans.converters.database.SkyObjectTypePropertyConverter +import nebulosa.api.database.BoxEntity import nebulosa.math.Angle import nebulosa.math.Velocity import nebulosa.nova.astrometry.Body 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 430272fab..8ce437070 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt @@ -9,7 +9,7 @@ import io.objectbox.BoxStore import nebulosa.api.atlas.SatelliteEntity import nebulosa.api.atlas.SimbadEntity import nebulosa.api.calibration.CalibrationFrameEntity -import nebulosa.api.entities.MyObjectBox +import nebulosa.api.database.MyObjectBox import nebulosa.api.locations.LocationEntity import nebulosa.api.preferences.PreferenceEntity import nebulosa.batch.processing.AsyncJobLauncher diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/ConstellationPropertyConverter.kt b/api/src/main/kotlin/nebulosa/api/beans/converters/database/ConstellationPropertyConverter.kt similarity index 91% rename from api/src/main/kotlin/nebulosa/api/beans/converters/ConstellationPropertyConverter.kt rename to api/src/main/kotlin/nebulosa/api/beans/converters/database/ConstellationPropertyConverter.kt index fade4e0fc..e1454963b 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/ConstellationPropertyConverter.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/converters/database/ConstellationPropertyConverter.kt @@ -1,4 +1,4 @@ -package nebulosa.api.beans.converters +package nebulosa.api.beans.converters.database import io.objectbox.converter.PropertyConverter import nebulosa.nova.astrometry.Constellation diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/FrameTypePropertyConverter.kt b/api/src/main/kotlin/nebulosa/api/beans/converters/database/FrameTypePropertyConverter.kt similarity index 90% rename from api/src/main/kotlin/nebulosa/api/beans/converters/FrameTypePropertyConverter.kt rename to api/src/main/kotlin/nebulosa/api/beans/converters/database/FrameTypePropertyConverter.kt index e9689f070..e160266ce 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/FrameTypePropertyConverter.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/converters/database/FrameTypePropertyConverter.kt @@ -1,4 +1,4 @@ -package nebulosa.api.beans.converters +package nebulosa.api.beans.converters.database import io.objectbox.converter.PropertyConverter import nebulosa.indi.device.camera.FrameType diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/SkyObjectTypePropertyConverter.kt b/api/src/main/kotlin/nebulosa/api/beans/converters/database/SkyObjectTypePropertyConverter.kt similarity index 91% rename from api/src/main/kotlin/nebulosa/api/beans/converters/SkyObjectTypePropertyConverter.kt rename to api/src/main/kotlin/nebulosa/api/beans/converters/database/SkyObjectTypePropertyConverter.kt index 29487c552..537f4ce88 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/SkyObjectTypePropertyConverter.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/converters/database/SkyObjectTypePropertyConverter.kt @@ -1,4 +1,4 @@ -package nebulosa.api.beans.converters +package nebulosa.api.beans.converters.database import io.objectbox.converter.PropertyConverter import nebulosa.skycatalog.SkyObjectType diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/indi/DeviceDeserializer.kt b/api/src/main/kotlin/nebulosa/api/beans/converters/indi/DeviceDeserializer.kt index fc2f56a62..11a3bf16a 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/indi/DeviceDeserializer.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/converters/indi/DeviceDeserializer.kt @@ -6,7 +6,7 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.node.TextNode -sealed class DeviceDeserializer(type: Class) : StdDeserializer(type) { +abstract class DeviceDeserializer(type: Class) : StdDeserializer(type) { protected abstract fun deviceFor(name: String): T? diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt index 4e7753fc4..9bf5519b2 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt @@ -1,8 +1,8 @@ package nebulosa.api.calibration import io.objectbox.annotation.* -import nebulosa.api.entities.BoxEntity -import nebulosa.api.beans.converters.FrameTypePropertyConverter +import nebulosa.api.beans.converters.database.FrameTypePropertyConverter +import nebulosa.api.database.BoxEntity import nebulosa.indi.device.camera.FrameType @Entity diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/indi/CameraDeserializer.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraDeserializer.kt similarity index 84% rename from api/src/main/kotlin/nebulosa/api/beans/converters/indi/CameraDeserializer.kt rename to api/src/main/kotlin/nebulosa/api/cameras/CameraDeserializer.kt index 4fd540c58..bbac37188 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/indi/CameraDeserializer.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraDeserializer.kt @@ -1,5 +1,6 @@ -package nebulosa.api.beans.converters.indi +package nebulosa.api.cameras +import nebulosa.api.beans.converters.indi.DeviceDeserializer import nebulosa.api.connection.ConnectionService import nebulosa.indi.device.camera.Camera import org.springframework.beans.factory.annotation.Autowired diff --git a/api/src/main/kotlin/nebulosa/api/entities/BoxEntity.kt b/api/src/main/kotlin/nebulosa/api/database/BoxEntity.kt similarity index 58% rename from api/src/main/kotlin/nebulosa/api/entities/BoxEntity.kt rename to api/src/main/kotlin/nebulosa/api/database/BoxEntity.kt index e66206f40..16091348c 100644 --- a/api/src/main/kotlin/nebulosa/api/entities/BoxEntity.kt +++ b/api/src/main/kotlin/nebulosa/api/database/BoxEntity.kt @@ -1,4 +1,4 @@ -package nebulosa.api.entities +package nebulosa.api.database interface BoxEntity { diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/indi/FocuserDeserializer.kt b/api/src/main/kotlin/nebulosa/api/focusers/FocuserDeserializer.kt similarity index 84% rename from api/src/main/kotlin/nebulosa/api/beans/converters/indi/FocuserDeserializer.kt rename to api/src/main/kotlin/nebulosa/api/focusers/FocuserDeserializer.kt index 1e66a9665..7bb27a1bf 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/indi/FocuserDeserializer.kt +++ b/api/src/main/kotlin/nebulosa/api/focusers/FocuserDeserializer.kt @@ -1,5 +1,6 @@ -package nebulosa.api.beans.converters.indi +package nebulosa.api.focusers +import nebulosa.api.beans.converters.indi.DeviceDeserializer import nebulosa.api.connection.ConnectionService import nebulosa.indi.device.focuser.Focuser import org.springframework.beans.factory.annotation.Autowired diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuideOutputDeserializer.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuideOutputDeserializer.kt new file mode 100644 index 000000000..6ede536fb --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuideOutputDeserializer.kt @@ -0,0 +1,16 @@ +package nebulosa.api.guiding + +import nebulosa.api.beans.converters.indi.DeviceDeserializer +import nebulosa.api.connection.ConnectionService +import nebulosa.indi.device.guide.GuideOutput +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Lazy +import org.springframework.stereotype.Component + +@Component +class GuideOutputDeserializer : DeviceDeserializer(GuideOutput::class.java) { + + @Autowired @Lazy private lateinit var connectionService: ConnectionService + + override fun deviceFor(name: String) = connectionService.guideOutput(name) +} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuideOutputSerializer.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuideOutputSerializer.kt new file mode 100644 index 000000000..3b39c21a2 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuideOutputSerializer.kt @@ -0,0 +1,20 @@ +package nebulosa.api.guiding + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import nebulosa.indi.device.guide.GuideOutput +import org.springframework.stereotype.Component + +@Component +class GuideOutputSerializer : StdSerializer(GuideOutput::class.java) { + + override fun serialize(value: GuideOutput, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeStartObject() + gen.writeStringField("name", value.name) + gen.writeBooleanField("connected", value.connected) + gen.writeBooleanField("canPulseGuide", value.canPulseGuide) + gen.writeBooleanField("pulseGuiding", value.pulseGuiding) + gen.writeEndObject() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/indi/INDIPropertySerializer.kt b/api/src/main/kotlin/nebulosa/api/indi/INDIPropertySerializer.kt similarity index 93% rename from api/src/main/kotlin/nebulosa/api/beans/converters/indi/INDIPropertySerializer.kt rename to api/src/main/kotlin/nebulosa/api/indi/INDIPropertySerializer.kt index fb536be46..9115b7b85 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/indi/INDIPropertySerializer.kt +++ b/api/src/main/kotlin/nebulosa/api/indi/INDIPropertySerializer.kt @@ -1,4 +1,4 @@ -package nebulosa.api.beans.converters.indi +package nebulosa.api.indi import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.SerializerProvider diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/indi/INDIPropertyVectorSerializer.kt b/api/src/main/kotlin/nebulosa/api/indi/INDIPropertyVectorSerializer.kt similarity index 96% rename from api/src/main/kotlin/nebulosa/api/beans/converters/indi/INDIPropertyVectorSerializer.kt rename to api/src/main/kotlin/nebulosa/api/indi/INDIPropertyVectorSerializer.kt index 3d773ad48..069753059 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/indi/INDIPropertyVectorSerializer.kt +++ b/api/src/main/kotlin/nebulosa/api/indi/INDIPropertyVectorSerializer.kt @@ -1,4 +1,4 @@ -package nebulosa.api.beans.converters.indi +package nebulosa.api.indi import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.SerializerProvider diff --git a/api/src/main/kotlin/nebulosa/api/locations/LocationEntity.kt b/api/src/main/kotlin/nebulosa/api/locations/LocationEntity.kt index 0a0072626..be1bcc29a 100644 --- a/api/src/main/kotlin/nebulosa/api/locations/LocationEntity.kt +++ b/api/src/main/kotlin/nebulosa/api/locations/LocationEntity.kt @@ -3,7 +3,7 @@ package nebulosa.api.locations import io.objectbox.annotation.Entity import io.objectbox.annotation.Id import jakarta.validation.constraints.NotBlank -import nebulosa.api.entities.BoxEntity +import nebulosa.api.database.BoxEntity import nebulosa.math.deg import nebulosa.math.m import nebulosa.nova.position.GeographicPosition diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/indi/MountDeserializer.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountDeserializer.kt similarity index 84% rename from api/src/main/kotlin/nebulosa/api/beans/converters/indi/MountDeserializer.kt rename to api/src/main/kotlin/nebulosa/api/mounts/MountDeserializer.kt index 37a716c4e..edfacb29e 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/indi/MountDeserializer.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountDeserializer.kt @@ -1,5 +1,6 @@ -package nebulosa.api.beans.converters.indi +package nebulosa.api.mounts +import nebulosa.api.beans.converters.indi.DeviceDeserializer import nebulosa.api.connection.ConnectionService import nebulosa.indi.device.mount.Mount import org.springframework.beans.factory.annotation.Autowired diff --git a/api/src/main/kotlin/nebulosa/api/preferences/PreferenceEntity.kt b/api/src/main/kotlin/nebulosa/api/preferences/PreferenceEntity.kt index 84570c502..2087fcbcc 100644 --- a/api/src/main/kotlin/nebulosa/api/preferences/PreferenceEntity.kt +++ b/api/src/main/kotlin/nebulosa/api/preferences/PreferenceEntity.kt @@ -4,7 +4,7 @@ import io.objectbox.annotation.ConflictStrategy import io.objectbox.annotation.Entity import io.objectbox.annotation.Id import io.objectbox.annotation.Unique -import nebulosa.api.entities.BoxEntity +import nebulosa.api.database.BoxEntity @Entity data class PreferenceEntity( diff --git a/api/src/main/kotlin/nebulosa/api/repositories/BoxRepository.kt b/api/src/main/kotlin/nebulosa/api/repositories/BoxRepository.kt index b098f4be6..98223b3c4 100644 --- a/api/src/main/kotlin/nebulosa/api/repositories/BoxRepository.kt +++ b/api/src/main/kotlin/nebulosa/api/repositories/BoxRepository.kt @@ -1,7 +1,7 @@ package nebulosa.api.repositories import io.objectbox.Box -import nebulosa.api.entities.BoxEntity +import nebulosa.api.database.BoxEntity abstract class BoxRepository : Collection { diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/indi/WheelDeserializer.kt b/api/src/main/kotlin/nebulosa/api/wheels/WheelDeserializer.kt similarity index 85% rename from api/src/main/kotlin/nebulosa/api/beans/converters/indi/WheelDeserializer.kt rename to api/src/main/kotlin/nebulosa/api/wheels/WheelDeserializer.kt index ab626b6af..35809545c 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/indi/WheelDeserializer.kt +++ b/api/src/main/kotlin/nebulosa/api/wheels/WheelDeserializer.kt @@ -1,5 +1,6 @@ -package nebulosa.api.beans.converters.indi +package nebulosa.api.wheels +import nebulosa.api.beans.converters.indi.DeviceDeserializer import nebulosa.api.connection.ConnectionService import nebulosa.indi.device.filterwheel.FilterWheel import org.springframework.beans.factory.annotation.Autowired From 038f28a601a8dc4c4bfe537903bc72bedd044cfa Mon Sep 17 00:00:00 2001 From: tiagohm Date: Wed, 14 Feb 2024 10:50:13 -0300 Subject: [PATCH 23/87] [api]: Add support to ASCOM Alpaca API --- .../alpaca/api/AlpacaCameraService.kt | 6 + .../alpaca/indi/devices/ASCOMDevice.kt | 6 +- .../indi/devices/cameras/ASCOMCamera.kt | 118 +++++++++++++++++- .../devices/cameras/ImageArrayElementType.kt | 15 +++ .../src/main/kotlin/nebulosa/fits/Fits.kt | 8 +- 5 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt index d6efe3447..11dd20f1c 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt @@ -1,5 +1,6 @@ package nebulosa.alpaca.api +import okhttp3.ResponseBody import retrofit2.Call import retrofit2.http.* @@ -236,4 +237,9 @@ interface AlpacaCameraService : AlpacaDeviceService { @PUT("api/v1/camera/{id}/stopexposure") fun stopExposure(@Path("id") id: Int): Call + + // https://github.com/ASCOMInitiative/ASCOMRemote/blob/main/Documentation/AlpacaImageBytes.pdf + @Headers("Accept: application/imagebytes") + @GET("api/v1/camera/{id}/imagearray") + fun imageArray(@Path("id") id: Int): Call } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt index 288301bbf..d117bfba9 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt @@ -129,10 +129,14 @@ abstract class ASCOMDevice : Device { } } - private inner class Refresher : Thread() { + private inner class Refresher : Thread("$name ASCOM Refresher") { private val stopwatch = Stopwatch() + init { + isDaemon = true + } + override fun run() { stopwatch.start() diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt index 064009d7b..9ba327c76 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt @@ -6,13 +6,18 @@ import nebulosa.alpaca.api.ConfiguredDevice import nebulosa.alpaca.api.PulseGuideDirection import nebulosa.alpaca.indi.client.AlpacaClient import nebulosa.alpaca.indi.devices.ASCOMDevice +import nebulosa.common.concurrency.latch.CountUpDownLatch +import nebulosa.fits.* import nebulosa.imaging.algorithms.transformation.CfaPattern import nebulosa.indi.device.Device import nebulosa.indi.device.camera.* import nebulosa.indi.device.camera.Camera.Companion.NANO_SECONDS import nebulosa.indi.device.guide.GuideOutputPulsingChanged +import nebulosa.indi.device.mount.Mount import nebulosa.indi.protocol.INDIProtocol import nebulosa.indi.protocol.PropertyState +import java.nio.ByteBuffer +import java.nio.file.Files import java.time.Duration import kotlin.math.max import kotlin.math.min @@ -124,9 +129,14 @@ data class ASCOMCamera( @Volatile private var cameraState = CameraState.IDLE @Volatile private var frameType = FrameType.LIGHT + @Volatile private var mount: Mount? = null + + private val imageReadyWaiter = ImageReadyWaiter() + private val savedImagePath = Files.createTempFile(name, ".fits") init { refresh(0L) + imageReadyWaiter.start() } override fun cooler(enabled: Boolean) { @@ -175,11 +185,14 @@ data class ASCOMCamera( override fun startCapture(exposureTime: Duration) { this.exposureTime = exposureTime - service.startExposure(device.number, exposureTime.toNanos() / NANO_SECONDS, frameType == FrameType.DARK).doRequest() + service.startExposure(device.number, exposureTime.toNanos() / NANO_SECONDS, frameType == FrameType.DARK).doRequest { + imageReadyWaiter.captureStarted() + } } override fun abortCapture() { service.abortExposure(device.number).doRequest() + imageReadyWaiter.captureAborted() } private fun pulseGuide(direction: PulseGuideDirection, duration: Duration) { @@ -213,6 +226,9 @@ data class ASCOMCamera( } override fun snoop(devices: Iterable) { + for (device in devices) { + if (device is Mount) mount = device + } } override fun handleMessage(message: INDIProtocol) { @@ -289,6 +305,7 @@ data class ASCOMCamera( override fun close() { super.close() reset() + imageReadyWaiter.interrupt() } @Synchronized @@ -578,6 +595,34 @@ data class ASCOMCamera( } } + private fun readImage() { + service.imageArray(device.number).execute().body()?.use { + val bytes = it.byteStream() + val metadata = ImageMetadata.from(bytes.readNBytes(44)) + + if (metadata.errorNumber != 0) { + LOG.error("failed to read image. device={}, error={}", name, metadata.errorNumber) + return + } + + val width = metadata.dimension1 + val height = metadata.dimension2 + + val header = Header() + header.add(Standard.SIMPLE, true) + + val imageData1 = FloatArrayImageData(width, height) + + val hdu = ImageHdu(header, arrayOf(imageData1)) + + val fits = Fits() + fits.add(hdu) + fits.writeTo() + + client.fireOnEventReceived(CameraFrameCaptured(this, fits, false)) + } ?: LOG.error("image body is null. device={}", name) + } + override fun toString() = "Camera(name=$name, connected=$connected, exposuring=$exposuring," + " hasCoolerControl=$hasCoolerControl, cooler=$cooler," + " hasDewHeater=$hasDewHeater, dewHeater=$dewHeater," + @@ -595,4 +640,75 @@ data class ASCOMCamera( " gainMax=$gainMax, offset=$offset, offsetMin=$offsetMin," + " offsetMax=$offsetMax, hasGuiderHead=$hasGuiderHead," + " canPulseGuide=$canPulseGuide, pulseGuiding=$pulseGuiding)" + + data class ImageMetadata( + @JvmField val metadataVersion: Int, // Bytes 0..3 - Metadata version = 1 + @JvmField val errorNumber: Int, // Bytes 4..7 - Alpaca error number or zero for success + @JvmField val clientTransactionID: Int, // Bytes 8..11 - Client's transaction ID + @JvmField val serverTransactionID: Int, // Bytes 12..15 - Device's transaction ID + @JvmField val dataStart: Int, // Bytes 16..19 - Offset of the start of the data bytes + @JvmField val imageElementType: ImageArrayElementType, // Bytes 20..23 - Element type of the source image array + @JvmField val transmissionElementType: Int, // Bytes 24..27 - Element type as sent over the network + @JvmField val rank: Int, // Bytes 28..31 - Image array rank (2 or 3) + @JvmField val dimension1: Int, // Bytes 32..35 - Length of image array first dimension + @JvmField val dimension2: Int, // Bytes 36..39 - Length of image array second dimension + @JvmField val dimension3: Int, // Bytes 40..43 - Length of image array third dimension (0 for 2D array) + ) { + + companion object { + + @JvmStatic + fun from(data: ByteBuffer) = ImageMetadata( + data.getInt(), data.getInt(), data.getInt(), data.getInt(), data.getInt(), + ImageArrayElementType.entries[data.getInt()], data.getInt(), data.getInt(), + data.getInt(), data.getInt(), data.getInt() + ) + + @JvmStatic + fun from(data: ByteArray) = from(ByteBuffer.wrap(data, 0, 44)) + } + } + + private inner class ImageReadyWaiter : Thread("$name ASCOM Image Ready Waiter") { + + private val latch = CountUpDownLatch(1) + + init { + isDaemon = true + } + + fun captureStarted() { + latch.reset() + } + + fun captureAborted() { + latch.countUp() + } + + override fun run() { + while (true) { + latch.await() + + while (latch.get()) { + val startTime = System.currentTimeMillis() + + service.isImageReady(device.number).doRequest { + if (it.value) { + latch.countUp() + readImage() + } + } + + if (!latch.get()) { + val endTime = System.currentTimeMillis() + val delayTime = 1000L - (endTime - startTime) + + if (delayTime > 1L) { + sleep(delayTime) + } + } + } + } + } + } } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt new file mode 100644 index 000000000..63d0d0e81 --- /dev/null +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt @@ -0,0 +1,15 @@ +package nebulosa.alpaca.indi.devices.cameras + +enum class ImageArrayElementType { + UNKNOWN, // 0 to 3 are values already used in the Alpaca standard + INT16, + INT32, + DOUBLE, + SINGLE, // 4 to 9 are an extension to include other numeric types + UINT64, + BYTE, + INT64, + UINT16, + UINT32, + +} diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/Fits.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/Fits.kt index 8399a7c1a..8e3fa850e 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/Fits.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/Fits.kt @@ -9,11 +9,11 @@ import java.io.File import java.nio.file.Path class Fits private constructor( - val source: SeekableSource, + val source: SeekableSource?, private val hdus: ArrayList>, ) : List> by hdus, Closeable { - constructor(source: SeekableSource) : this(source, ArrayList(4)) + constructor(source: SeekableSource? = null) : this(source, ArrayList(4)) constructor(path: File) : this(path.seekableSource()) @@ -22,6 +22,8 @@ class Fits private constructor( constructor(path: String) : this(File(path)) fun readHdu(): Hdu<*>? { + if (source == null) return null + return try { return FitsIO.read(source).also(::add) } catch (ignored: EOFException) { @@ -52,6 +54,6 @@ class Fits private constructor( } override fun close() { - source.close() + source?.close() } } From 35e863b58f1aa72a1501d0fdc9d5d6e4616ea261 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Wed, 14 Feb 2024 22:16:53 -0300 Subject: [PATCH 24/87] [api]: Improve FITS --- .../api/alignment/polar/tppa/TPPAStep.kt | 4 +- .../calibration/CalibrationFrameService.kt | 14 ++-- .../api/cameras/CameraExposureStep.kt | 15 +++- .../nebulosa/api/framing/FramingService.kt | 4 +- .../kotlin/nebulosa/api/image/ImageBucket.kt | 4 +- .../api/wizard/flat/FlatWizardStep.kt | 6 +- .../indi/devices/cameras/ASCOMCamera.kt | 38 +++++++++-- .../src/main/kotlin/nebulosa/fits/Fits.kt | 44 +++--------- .../main/kotlin/nebulosa/fits/FitsHelper.kt | 14 +++- .../src/main/kotlin/nebulosa/fits/FitsIO.kt | 3 + .../nebulosa/fits/FloatArrayImageData.kt | 2 +- .../src/test/kotlin/FitsWriteTest.kt | 5 +- .../src/test/kotlin/Hips2FitsServiceTest.kt | 4 +- .../src/main/kotlin/nebulosa/imaging/Image.kt | 2 +- .../src/test/kotlin/TransformAlgorithmTest.kt | 7 +- .../indi/client/device/camera/CameraDevice.kt | 34 +++++----- .../indi/device/camera/CameraFrameCaptured.kt | 6 +- .../kotlin/nebulosa/test/FitsStringSpec.kt | 68 +++++++++---------- .../nebulosa/test/Hips2FitsStringSpec.kt | 5 +- .../watney/plate/solving/WatneyPlateSolver.kt | 6 +- nebulosa-wcs/src/test/kotlin/LibWCSTest.kt | 5 +- 21 files changed, 156 insertions(+), 134 deletions(-) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt index 84c43c79a..251c9244c 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt @@ -12,7 +12,7 @@ import nebulosa.batch.processing.StepExecution import nebulosa.batch.processing.StepResult import nebulosa.common.concurrency.latch.Pauseable import nebulosa.common.time.Stopwatch -import nebulosa.fits.Fits +import nebulosa.fits.fits import nebulosa.imaging.Image import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.mount.Mount @@ -120,7 +120,7 @@ data class TPPAStep( if (!cancellationToken.isCancelled) { val savedPath = cameraExposureStep.savedPath ?: return StepResult.FINISHED - image = Fits(savedPath).also(Fits::read).use { image?.load(it, false) ?: Image.open(it, false) } + image = savedPath.fits().let { image?.load(it, false) ?: Image.open(it, false) } val radius = if (mount == null) 0.0 else ThreePointPolarAlignment.DEFAULT_RADIUS diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt index 4a30e311f..0a62cba5f 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt @@ -5,10 +5,10 @@ import nebulosa.imaging.Image import nebulosa.imaging.algorithms.transformation.correction.BiasSubtraction import nebulosa.imaging.algorithms.transformation.correction.DarkSubtraction import nebulosa.imaging.algorithms.transformation.correction.FlatCorrection -import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.FrameType import nebulosa.log.loggerFor import org.springframework.stereotype.Service +import java.io.File import java.nio.file.Path import java.util.* import kotlin.io.path.isDirectory @@ -35,7 +35,7 @@ class CalibrationFrameService( var calibrationImage = Image(transformedImage.width, transformedImage.height, Header.EMPTY, transformedImage.mono) if (biasFrame != null) { - calibrationImage = Fits(biasFrame.path!!).also(Fits::read).use(calibrationImage::load)!! + calibrationImage = File(biasFrame.path!!).fits().let(calibrationImage::load)!! transformedImage = transformedImage.transform(BiasSubtraction(calibrationImage)) LOG.info("bias frame subtraction applied. frame={}", biasFrame) } else { @@ -46,7 +46,7 @@ class CalibrationFrameService( } if (darkFrame != null) { - calibrationImage = Fits(darkFrame.path!!).also(Fits::read).use(calibrationImage::load)!! + calibrationImage = File(darkFrame.path!!).fits().let(calibrationImage::load)!! transformedImage = transformedImage.transform(DarkSubtraction(calibrationImage)) LOG.info("dark frame subtraction applied. frame={}", darkFrame) } else { @@ -57,7 +57,7 @@ class CalibrationFrameService( } if (flatFrame != null) { - calibrationImage = Fits(flatFrame.path!!).also(Fits::read).use(calibrationImage::load)!! + calibrationImage = File(flatFrame.path!!).fits().let(calibrationImage::load)!! transformedImage = transformedImage.transform(FlatCorrection(calibrationImage)) LOG.info("flat frame correction applied. frame={}", flatFrame) } else { @@ -98,9 +98,9 @@ class CalibrationFrameService( calibrationFrameRepository.delete(camera, "$file") try { - Fits(file).also(Fits::read).use { fits -> - val (header) = fits.filterIsInstance().firstOrNull() ?: return@use - val frameType = header.frameType?.takeIf { it != FrameType.LIGHT } ?: return@use + file.fits().also { fits -> + val (header) = fits.filterIsInstance().firstOrNull() ?: return@also + val frameType = header.frameType?.takeIf { it != FrameType.LIGHT } ?: return@also val exposureTime = if (frameType == FrameType.DARK) header.exposureTimeInMicroseconds else 0L val temperature = if (frameType == FrameType.DARK) header.temperature else 999.0 diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt index c1ad00a40..b55358c11 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt @@ -11,10 +11,12 @@ import nebulosa.batch.processing.StepResult import nebulosa.batch.processing.delay.DelayStep import nebulosa.batch.processing.delay.DelayStepListener import nebulosa.common.concurrency.latch.CountUpDownLatch +import nebulosa.fits.Fits import nebulosa.indi.device.camera.* import nebulosa.io.transferAndClose import nebulosa.log.debug import nebulosa.log.loggerFor +import okio.sink import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -64,7 +66,7 @@ data class CameraExposureStep( if (event.device === camera) { when (event) { is CameraFrameCaptured -> { - save(event.fits) + save(event.stream, event.fits) } is CameraExposureAborted, is CameraExposureFailed, @@ -166,14 +168,21 @@ data class CameraExposureStep( } } - private fun save(stream: InputStream) { + private fun save(stream: InputStream?, fits: Fits?) { try { savedPath = request.makeSavePath(camera) LOG.info("saving FITS. path={}", savedPath) savedPath!!.createParentDirectories() - stream.transferAndClose(savedPath!!.outputStream()) + + if (stream != null) { + stream.transferAndClose(savedPath!!.outputStream()) + } else if (fits != null) { + savedPath!!.outputStream().use { fits.writeTo(it.sink()) } + } else { + return + } listeners.forEach { it.onExposureFinished(this, stepExecution!!) } } catch (e: Throwable) { diff --git a/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt b/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt index cb4b24bbb..64ab26ea2 100644 --- a/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt +++ b/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt @@ -1,6 +1,6 @@ package nebulosa.api.framing -import nebulosa.fits.Fits +import nebulosa.fits.fits import nebulosa.hips2fits.FormatOutputType import nebulosa.hips2fits.Hips2FitsService import nebulosa.imaging.Image @@ -32,7 +32,7 @@ class FramingService(private val hips2FitsService: Hips2FitsService) { ).execute().body() ?: return null responseBody.use { it.byteStream().transferAndCloseOutput(DEFAULT_PATH.outputStream()) } - val image = Fits(DEFAULT_PATH).also(Fits::read).use(Image::open) + val image = DEFAULT_PATH.fits().let(Image::open) val solution = PlateSolution.from(image.header) LOG.info("framing file loaded. calibration={}", solution) return Triple(image, solution, DEFAULT_PATH) diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt b/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt index 82c31c173..6b484128c 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt @@ -1,6 +1,6 @@ package nebulosa.api.image -import nebulosa.fits.Fits +import nebulosa.fits.fits import nebulosa.imaging.Image import nebulosa.plate.solving.PlateSolution import org.springframework.stereotype.Component @@ -27,7 +27,7 @@ class ImageBucket { fun open(path: Path, debayer: Boolean = true, solution: PlateSolution? = null, force: Boolean = false): Image { val openedImage = this[path] if (openedImage != null && !force) return openedImage.first - val image = Fits(path).also(Fits::read).use { openedImage?.first?.load(it) ?: Image.open(it, debayer) } + val image = path.fits().let { openedImage?.first?.load(it) ?: Image.open(it, debayer) } put(path, image, solution ?: PlateSolution.from(image.header)) return image } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt index a78f00206..9b4eba2ec 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt @@ -7,7 +7,7 @@ import nebulosa.api.cameras.CameraExposureStep.Companion.makeSavePath import nebulosa.batch.processing.Step import nebulosa.batch.processing.StepExecution import nebulosa.batch.processing.StepResult -import nebulosa.fits.Fits +import nebulosa.fits.fits import nebulosa.imaging.Image import nebulosa.imaging.algorithms.computation.Statistics import nebulosa.indi.device.camera.Camera @@ -86,9 +86,7 @@ data class FlatWizardStep( val savedPath = cameraExposureStep.savedPath if (!stopped && savedPath != null) { - image = Fits(savedPath).also(Fits::read).use { fits -> - image?.load(fits, false) ?: Image.open(fits, false) - } + image = savedPath.fits().let { image?.load(it, false) ?: Image.open(it, false) } val statistics = STATISTICS.compute(image!!) LOG.info("flat frame captured. duration={}, statistics={}", exposureTime, statistics) diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt index 9ba327c76..a0d82fbff 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt @@ -16,6 +16,10 @@ import nebulosa.indi.device.guide.GuideOutputPulsingChanged import nebulosa.indi.device.mount.Mount import nebulosa.indi.protocol.INDIProtocol import nebulosa.indi.protocol.PropertyState +import nebulosa.io.readDoubleLe +import nebulosa.io.readFloatLe +import okio.buffer +import okio.source import java.nio.ByteBuffer import java.nio.file.Files import java.time.Duration @@ -607,19 +611,43 @@ data class ASCOMCamera( val width = metadata.dimension1 val height = metadata.dimension2 + val planes = max(1, metadata.dimension3) + val source = bytes.source().buffer() + val imageData = Array(planes) { FloatArrayImageData(width, height, FloatArray(width * height)) } + var i = 0 + + for (x in 0 until width) { + for (y in 0 until height) { + for (p in 0 until planes) { + val pixel = when (metadata.imageElementType) { + ImageArrayElementType.BYTE -> (source.readByte().toInt() and 0xFF) / 255f + ImageArrayElementType.INT16, + ImageArrayElementType.UINT16 -> (source.readShortLe().toInt() + 32768) / 65535f + ImageArrayElementType.INT32, + ImageArrayElementType.UINT32 -> ((source.readIntLe().toLong() + 2147483648) / 4294967295.0).toFloat() + ImageArrayElementType.INT64, + ImageArrayElementType.UINT64 -> return + ImageArrayElementType.SINGLE -> source.readFloatLe() + ImageArrayElementType.DOUBLE -> source.readDoubleLe().toFloat() + ImageArrayElementType.UNKNOWN -> return + } + + imageData[p].data[i] = pixel + } + + i++ + } + } val header = Header() header.add(Standard.SIMPLE, true) - val imageData1 = FloatArrayImageData(width, height) - - val hdu = ImageHdu(header, arrayOf(imageData1)) + val hdu = ImageHdu(header, imageData) val fits = Fits() fits.add(hdu) - fits.writeTo() - client.fireOnEventReceived(CameraFrameCaptured(this, fits, false)) + client.fireOnEventReceived(CameraFrameCaptured(this, null, fits, false)) } ?: LOG.error("image body is null. device={}", name) } diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/Fits.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/Fits.kt index 8e3fa850e..4ecdfc0f3 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/Fits.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/Fits.kt @@ -1,29 +1,17 @@ package nebulosa.fits import nebulosa.io.SeekableSource -import nebulosa.io.seekableSource import okio.Sink -import java.io.Closeable import java.io.EOFException -import java.io.File -import java.nio.file.Path +import java.util.* -class Fits private constructor( - val source: SeekableSource?, - private val hdus: ArrayList>, -) : List> by hdus, Closeable { +open class Fits : LinkedList> { - constructor(source: SeekableSource? = null) : this(source, ArrayList(4)) + constructor() : super() - constructor(path: File) : this(path.seekableSource()) - - constructor(path: Path) : this(path.toFile()) - - constructor(path: String) : this(File(path)) - - fun readHdu(): Hdu<*>? { - if (source == null) return null + constructor(hdus: Collection>) : super(hdus) + fun readHdu(source: SeekableSource): Hdu<*>? { return try { return FitsIO.read(source).also(::add) } catch (ignored: EOFException) { @@ -31,29 +19,13 @@ class Fits private constructor( } } - fun read() { + fun read(source: SeekableSource) { while (true) { - readHdu() ?: break + readHdu(source) ?: break } } - fun add(hdu: Hdu<*>) { - hdus.add(hdu) - } - - fun remove(hdu: Hdu<*>): Boolean { - return hdus.remove(hdu) - } - - fun clear() { - hdus.clear() - } - fun writeTo(sink: Sink) { - hdus.forEach { FitsIO.write(sink, it) } - } - - override fun close() { - source?.close() + forEach { FitsIO.write(sink, it) } } } diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt index 71bdd74fc..c500511b7 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt @@ -1,17 +1,21 @@ +@file:Suppress("NOTHING_TO_INLINE") + package nebulosa.fits +import nebulosa.io.SeekableSource +import nebulosa.io.seekableSource import nebulosa.math.Angle import nebulosa.math.deg +import java.io.File +import java.nio.file.Path import java.time.Duration import java.time.LocalDateTime -@Suppress("NOTHING_TO_INLINE") inline fun Header.clone() = Header(this) inline val Header.naxis get() = getInt(Standard.NAXIS, -1) -@Suppress("NOTHING_TO_INLINE") inline fun Header.naxis(n: Int) = getInt(Standard.NAXISn.n(n), 0) inline val Header.width @@ -73,3 +77,9 @@ inline val Header.frame inline val Header.instrument get() = getStringOrNull(Standard.INSTRUME)?.ifBlank { null }?.trim() + +inline fun SeekableSource.fits() = Fits().also { it.read(this) } + +inline fun Path.fits() = toFile().fits() + +inline fun File.fits() = Fits().also { seekableSource().use(it::read) } diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsIO.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsIO.kt index d0c951efe..c1e5d70d5 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsIO.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsIO.kt @@ -1,8 +1,11 @@ package nebulosa.fits import nebulosa.io.SeekableSource +import nebulosa.io.seekableSource import okio.Sink +import java.io.File import java.io.IOException +import java.nio.file.Path object FitsIO : FitsReader, FitsWriter { diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FloatArrayImageData.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FloatArrayImageData.kt index cb6733c16..8fc722ad6 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FloatArrayImageData.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FloatArrayImageData.kt @@ -9,7 +9,7 @@ import java.nio.ByteBuffer data class FloatArrayImageData( override val width: Int, override val height: Int, - val data: FloatArray, + @JvmField val data: FloatArray, ) : ImageData { override val bitpix = Bitpix.FLOAT diff --git a/nebulosa-fits/src/test/kotlin/FitsWriteTest.kt b/nebulosa-fits/src/test/kotlin/FitsWriteTest.kt index aeaf4449b..aca6aba6f 100644 --- a/nebulosa-fits/src/test/kotlin/FitsWriteTest.kt +++ b/nebulosa-fits/src/test/kotlin/FitsWriteTest.kt @@ -1,6 +1,6 @@ import io.kotest.matchers.shouldBe -import nebulosa.fits.Fits import nebulosa.fits.ImageHdu +import nebulosa.fits.fits import nebulosa.io.sink import nebulosa.io.source import nebulosa.test.FitsStringSpec @@ -15,9 +15,8 @@ class FitsWriteTest : FitsStringSpec() { hdu0.write(data.sink()) data.toByteString(2880, 66240).md5().hex() shouldBe "e1735e21c94dc49885fabc429406e573" - val fits = Fits(data.source()).also(Fits::read) + val fits = data.source().fits() val hdu1 = fits.filterIsInstance().first() - fits.close() hdu0.header shouldBe hdu1.header } diff --git a/nebulosa-hips2fits/src/test/kotlin/Hips2FitsServiceTest.kt b/nebulosa-hips2fits/src/test/kotlin/Hips2FitsServiceTest.kt index f60e0f160..62818de0a 100644 --- a/nebulosa-hips2fits/src/test/kotlin/Hips2FitsServiceTest.kt +++ b/nebulosa-hips2fits/src/test/kotlin/Hips2FitsServiceTest.kt @@ -19,14 +19,12 @@ class Hips2FitsServiceTest : StringSpec() { .execute() .body() .shouldNotBeNull() - val fits = responseBody.use { Fits(it.bytes().source()) } - fits.read() + val fits = responseBody.use { it.bytes().source().fits() } val hdu = fits.filterIsInstance().first().header hdu.width shouldBeExactly 1200 hdu.height shouldBeExactly 900 hdu.rightAscension.toDegrees shouldBeExactly 201.36506337683 hdu.declination.toDegrees shouldBeExactly -43.01911250808 - fits.close() } } } diff --git a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Image.kt b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Image.kt index 8b8eae9d3..5d8ce9a55 100644 --- a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Image.kt +++ b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Image.kt @@ -364,7 +364,7 @@ class Image( val width = bufferedImage.width val height = bufferedImage.height val mono = bufferedImage.type == TYPE_BYTE_GRAY - || bufferedImage.type == TYPE_USHORT_GRAY + || bufferedImage.type == TYPE_USHORT_GRAY header.add(Standard.SIMPLE, true) header.add(Standard.BITPIX, Bitpix.FLOAT.code) diff --git a/nebulosa-imaging/src/test/kotlin/TransformAlgorithmTest.kt b/nebulosa-imaging/src/test/kotlin/TransformAlgorithmTest.kt index e8964095e..954045d31 100644 --- a/nebulosa-imaging/src/test/kotlin/TransformAlgorithmTest.kt +++ b/nebulosa-imaging/src/test/kotlin/TransformAlgorithmTest.kt @@ -2,12 +2,13 @@ import io.kotest.matchers.booleans.shouldBeFalse import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.ints.shouldBeExactly import io.kotest.matchers.shouldBe -import nebulosa.fits.Fits +import nebulosa.fits.fits import nebulosa.imaging.Image import nebulosa.imaging.ImageChannel import nebulosa.imaging.algorithms.transformation.* import nebulosa.imaging.algorithms.transformation.convolution.* import nebulosa.test.FitsStringSpec +import java.io.File class TransformAlgorithmTest : FitsStringSpec() { @@ -267,13 +268,13 @@ class TransformAlgorithmTest : FitsStringSpec() { nImage.save("color-grayscale-y").second shouldBe "24dd4a7e0fa9e4be34c53c924a78a940" } "color:debayer" { - val fits = Fits("src/test/resources/Debayer.fits").also(Fits::read) + val fits = File("src/test/resources/Debayer.fits").fits() val mImage = Image.open(fits) val nImage = mImage.transform(AutoScreenTransformFunction) nImage.save("color-debayer").second shouldBe "86b5bdd67dfd6bbf5495afae4bf2bc04" } "color:no-debayer" { - val fits = Fits("src/test/resources/Debayer.fits").also(Fits::read) + val fits = File("src/test/resources/Debayer.fits").fits() val mImage = Image.open(fits, false) val nImage = mImage.transform(AutoScreenTransformFunction) nImage.save("color-no-debayer").second shouldBe "958ccea020deec1f0c075042a9ba37c3" diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt index 537fe84f3..1fbd68d10 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt @@ -295,7 +295,7 @@ internal open class CameraDevice( val ccd1 = message["CCD1"]!! val fits = Base64InputStream(ccd1.value) val compressed = COMPRESSION_FORMATS.any { ccd1.format.endsWith(it, true) } - handler.fireOnEventReceived(CameraFrameCaptured(this, fits, compressed)) + handler.fireOnEventReceived(CameraFrameCaptured(this, fits, null, compressed)) } "CCD2" -> { // TODO: Handle Guider Head frame. @@ -425,22 +425,22 @@ internal open class CameraDevice( } override fun toString() = "Camera(name=$name, connected=$connected, exposuring=$exposuring," + - " hasCoolerControl=$hasCoolerControl, cooler=$cooler," + - " hasDewHeater=$hasDewHeater, dewHeater=$dewHeater," + - " frameFormats=$frameFormats, canAbort=$canAbort," + - " cfaOffsetX=$cfaOffsetX, cfaOffsetY=$cfaOffsetY, cfaType=$cfaType," + - " exposureMin=$exposureMin, exposureMax=$exposureMax," + - " exposureState=$exposureState, exposureTime=$exposureTime," + - " hasCooler=$hasCooler, hasThermometer=$hasThermometer, canSetTemperature=$canSetTemperature," + - " temperature=$temperature, canSubFrame=$canSubFrame," + - " x=$x, minX=$minX, maxX=$maxX, y=$y, minY=$minY, maxY=$maxY," + - " width=$width, minWidth=$minWidth, maxWidth=$maxWidth, height=$height," + - " minHeight=$minHeight, maxHeight=$maxHeight," + - " canBin=$canBin, maxBinX=$maxBinX, maxBinY=$maxBinY," + - " binX=$binX, binY=$binY, gain=$gain, gainMin=$gainMin," + - " gainMax=$gainMax, offset=$offset, offsetMin=$offsetMin," + - " offsetMax=$offsetMax, hasGuiderHead=$hasGuiderHead," + - " canPulseGuide=$canPulseGuide, pulseGuiding=$pulseGuiding)" + " hasCoolerControl=$hasCoolerControl, cooler=$cooler," + + " hasDewHeater=$hasDewHeater, dewHeater=$dewHeater," + + " frameFormats=$frameFormats, canAbort=$canAbort," + + " cfaOffsetX=$cfaOffsetX, cfaOffsetY=$cfaOffsetY, cfaType=$cfaType," + + " exposureMin=$exposureMin, exposureMax=$exposureMax," + + " exposureState=$exposureState, exposureTime=$exposureTime," + + " hasCooler=$hasCooler, hasThermometer=$hasThermometer, canSetTemperature=$canSetTemperature," + + " temperature=$temperature, canSubFrame=$canSubFrame," + + " x=$x, minX=$minX, maxX=$maxX, y=$y, minY=$minY, maxY=$maxY," + + " width=$width, minWidth=$minWidth, maxWidth=$maxWidth, height=$height," + + " minHeight=$minHeight, maxHeight=$maxHeight," + + " canBin=$canBin, maxBinX=$maxBinX, maxBinY=$maxBinY," + + " binX=$binX, binY=$binY, gain=$gain, gainMin=$gainMin," + + " gainMax=$gainMax, offset=$offset, offsetMin=$offsetMin," + + " offsetMax=$offsetMax, hasGuiderHead=$hasGuiderHead," + + " canPulseGuide=$canPulseGuide, pulseGuiding=$pulseGuiding)" companion object { diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/CameraFrameCaptured.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/CameraFrameCaptured.kt index a97f35c21..d75cce06d 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/CameraFrameCaptured.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/CameraFrameCaptured.kt @@ -1,9 +1,11 @@ package nebulosa.indi.device.camera +import nebulosa.fits.Fits import java.io.InputStream data class CameraFrameCaptured( override val device: Camera, - val fits: InputStream, - val compressed: Boolean, + @JvmField val stream: InputStream?, + @JvmField val fits: Fits?, + @JvmField val compressed: Boolean, ) : CameraEvent diff --git a/nebulosa-test/src/main/kotlin/nebulosa/test/FitsStringSpec.kt b/nebulosa-test/src/main/kotlin/nebulosa/test/FitsStringSpec.kt index ab24cb6af..c03ce58de 100644 --- a/nebulosa-test/src/main/kotlin/nebulosa/test/FitsStringSpec.kt +++ b/nebulosa-test/src/main/kotlin/nebulosa/test/FitsStringSpec.kt @@ -1,56 +1,56 @@ package nebulosa.test import io.kotest.core.spec.style.StringSpec -import nebulosa.fits.Fits +import nebulosa.fits.fits import nebulosa.io.transferAndCloseOutput import okhttp3.OkHttpClient import okhttp3.Request import okio.ByteString.Companion.toByteString import java.awt.image.BufferedImage +import java.io.File import java.nio.file.Path import java.util.concurrent.TimeUnit import java.util.zip.ZipInputStream import javax.imageio.ImageIO import kotlin.io.path.* - @Suppress("PropertyName") abstract class FitsStringSpec : StringSpec() { protected val FITS_DIR = "../data/fits" - protected val NGC3344_COLOR_8 by lazy { Fits("$FITS_DIR/NGC3344.Color.8.fits").also(Fits::read) } - protected val NGC3344_COLOR_16 by lazy { Fits("$FITS_DIR/NGC3344.Color.16.fits").also(Fits::read) } - protected val NGC3344_COLOR_32 by lazy { Fits("$FITS_DIR/NGC3344.Color.32.fits").also(Fits::read) } - protected val NGC3344_COLOR_F32 by lazy { Fits("$FITS_DIR/NGC3344.Color.F32.fits").also(Fits::read) } - protected val NGC3344_COLOR_F64 by lazy { Fits("$FITS_DIR/NGC3344.Color.F64.fits").also(Fits::read) } - protected val NGC3344_MONO_8 by lazy { Fits("$FITS_DIR/NGC3344.Mono.8.fits").also(Fits::read) } - protected val NGC3344_MONO_16 by lazy { Fits("$FITS_DIR/NGC3344.Mono.16.fits").also(Fits::read) } - protected val NGC3344_MONO_32 by lazy { Fits("$FITS_DIR/NGC3344.Mono.32.fits").also(Fits::read) } - protected val NGC3344_MONO_F32 by lazy { Fits("$FITS_DIR/NGC3344.Mono.F32.fits").also(Fits::read) } - protected val NGC3344_MONO_F64 by lazy { Fits("$FITS_DIR/NGC3344.Mono.F64.fits").also(Fits::read) } - protected val M6707HH by lazy { Fits(download("M6707HH.fits", ASTROPY_PHOTOMETRY_URL)).also(Fits::read) } - protected val STAR_FOCUS_1 by lazy { Fits("$FITS_DIR/STAR_FOCUS_1.fits").also(Fits::read) } - protected val STAR_FOCUS_2 by lazy { Fits("$FITS_DIR/STAR_FOCUS_2.fits").also(Fits::read) } - protected val STAR_FOCUS_3 by lazy { Fits("$FITS_DIR/STAR_FOCUS_3.fits").also(Fits::read) } - protected val STAR_FOCUS_4 by lazy { Fits("$FITS_DIR/STAR_FOCUS_4.fits").also(Fits::read) } - protected val STAR_FOCUS_5 by lazy { Fits("$FITS_DIR/STAR_FOCUS_5.fits").also(Fits::read) } - protected val STAR_FOCUS_6 by lazy { Fits("$FITS_DIR/STAR_FOCUS_6.fits").also(Fits::read) } - protected val STAR_FOCUS_7 by lazy { Fits("$FITS_DIR/STAR_FOCUS_7.fits").also(Fits::read) } - protected val STAR_FOCUS_8 by lazy { Fits("$FITS_DIR/STAR_FOCUS_8.fits").also(Fits::read) } - protected val STAR_FOCUS_9 by lazy { Fits("$FITS_DIR/STAR_FOCUS_9.fits").also(Fits::read) } - protected val STAR_FOCUS_10 by lazy { Fits("$FITS_DIR/STAR_FOCUS_10.fits").also(Fits::read) } - protected val STAR_FOCUS_11 by lazy { Fits("$FITS_DIR/STAR_FOCUS_11.fits").also(Fits::read) } - protected val STAR_FOCUS_12 by lazy { Fits("$FITS_DIR/STAR_FOCUS_12.fits").also(Fits::read) } - protected val STAR_FOCUS_13 by lazy { Fits("$FITS_DIR/STAR_FOCUS_13.fits").also(Fits::read) } - protected val STAR_FOCUS_14 by lazy { Fits("$FITS_DIR/STAR_FOCUS_14.fits").also(Fits::read) } - protected val STAR_FOCUS_15 by lazy { Fits("$FITS_DIR/STAR_FOCUS_15.fits").also(Fits::read) } - protected val STAR_FOCUS_16 by lazy { Fits("$FITS_DIR/STAR_FOCUS_16.fits").also(Fits::read) } - protected val STAR_FOCUS_17 by lazy { Fits("$FITS_DIR/STAR_FOCUS_17.fits").also(Fits::read) } - protected val UNCALIBRATED by lazy { Fits("$FITS_DIR/UNCALIBRATED.fits").also(Fits::read) } - protected val DARK by lazy { Fits("$FITS_DIR/DARK.fits").also(Fits::read) } - protected val FLAT by lazy { Fits("$FITS_DIR/FLAT.fits").also(Fits::read) } - protected val BIAS by lazy { Fits("$FITS_DIR/BIAS.fits").also(Fits::read) } + protected val NGC3344_COLOR_8 by lazy { File("$FITS_DIR/NGC3344.Color.8.fits").fits() } + protected val NGC3344_COLOR_16 by lazy { File("$FITS_DIR/NGC3344.Color.16.fits").fits() } + protected val NGC3344_COLOR_32 by lazy { File("$FITS_DIR/NGC3344.Color.32.fits").fits() } + protected val NGC3344_COLOR_F32 by lazy { File("$FITS_DIR/NGC3344.Color.F32.fits").fits() } + protected val NGC3344_COLOR_F64 by lazy { File("$FITS_DIR/NGC3344.Color.F64.fits").fits() } + protected val NGC3344_MONO_8 by lazy { File("$FITS_DIR/NGC3344.Mono.8.fits").fits() } + protected val NGC3344_MONO_16 by lazy { File("$FITS_DIR/NGC3344.Mono.16.fits").fits() } + protected val NGC3344_MONO_32 by lazy { File("$FITS_DIR/NGC3344.Mono.32.fits").fits() } + protected val NGC3344_MONO_F32 by lazy { File("$FITS_DIR/NGC3344.Mono.F32.fits").fits() } + protected val NGC3344_MONO_F64 by lazy { File("$FITS_DIR/NGC3344.Mono.F64.fits").fits() } + protected val M6707HH by lazy { download("M6707HH.fits", ASTROPY_PHOTOMETRY_URL).fits() } + protected val STAR_FOCUS_1 by lazy { File("$FITS_DIR/STAR_FOCUS_1.fits").fits() } + protected val STAR_FOCUS_2 by lazy { File("$FITS_DIR/STAR_FOCUS_2.fits").fits() } + protected val STAR_FOCUS_3 by lazy { File("$FITS_DIR/STAR_FOCUS_3.fits").fits() } + protected val STAR_FOCUS_4 by lazy { File("$FITS_DIR/STAR_FOCUS_4.fits").fits() } + protected val STAR_FOCUS_5 by lazy { File("$FITS_DIR/STAR_FOCUS_5.fits").fits() } + protected val STAR_FOCUS_6 by lazy { File("$FITS_DIR/STAR_FOCUS_6.fits").fits() } + protected val STAR_FOCUS_7 by lazy { File("$FITS_DIR/STAR_FOCUS_7.fits").fits() } + protected val STAR_FOCUS_8 by lazy { File("$FITS_DIR/STAR_FOCUS_8.fits").fits() } + protected val STAR_FOCUS_9 by lazy { File("$FITS_DIR/STAR_FOCUS_9.fits").fits() } + protected val STAR_FOCUS_10 by lazy { File("$FITS_DIR/STAR_FOCUS_10.fits").fits() } + protected val STAR_FOCUS_11 by lazy { File("$FITS_DIR/STAR_FOCUS_11.fits").fits() } + protected val STAR_FOCUS_12 by lazy { File("$FITS_DIR/STAR_FOCUS_12.fits").fits() } + protected val STAR_FOCUS_13 by lazy { File("$FITS_DIR/STAR_FOCUS_13.fits").fits() } + protected val STAR_FOCUS_14 by lazy { File("$FITS_DIR/STAR_FOCUS_14.fits").fits() } + protected val STAR_FOCUS_15 by lazy { File("$FITS_DIR/STAR_FOCUS_15.fits").fits() } + protected val STAR_FOCUS_16 by lazy { File("$FITS_DIR/STAR_FOCUS_16.fits").fits() } + protected val STAR_FOCUS_17 by lazy { File("$FITS_DIR/STAR_FOCUS_17.fits").fits() } + protected val UNCALIBRATED by lazy { File("$FITS_DIR/UNCALIBRATED.fits").fits() } + protected val DARK by lazy { File("$FITS_DIR/DARK.fits").fits() } + protected val FLAT by lazy { File("$FITS_DIR/FLAT.fits").fits() } + protected val BIAS by lazy { File("$FITS_DIR/BIAS.fits").fits() } protected fun BufferedImage.save(name: String): Pair { val path = Path.of("src", "test", "resources", "saved", "$name.png").createParentDirectories() diff --git a/nebulosa-test/src/main/kotlin/nebulosa/test/Hips2FitsStringSpec.kt b/nebulosa-test/src/main/kotlin/nebulosa/test/Hips2FitsStringSpec.kt index fdf82a229..5c5c730b6 100644 --- a/nebulosa-test/src/main/kotlin/nebulosa/test/Hips2FitsStringSpec.kt +++ b/nebulosa-test/src/main/kotlin/nebulosa/test/Hips2FitsStringSpec.kt @@ -1,6 +1,6 @@ package nebulosa.test -import nebulosa.fits.Fits +import nebulosa.fits.fits import nebulosa.hips2fits.Hips2FitsService import nebulosa.hips2fits.HipsSurvey import nebulosa.io.transferAndCloseOutput @@ -13,9 +13,10 @@ import kotlin.io.path.exists import kotlin.io.path.fileSize import kotlin.io.path.outputStream +@Suppress("PropertyName") abstract class Hips2FitsStringSpec : FitsStringSpec() { - protected val M31 by lazy { Fits(download("00 42 44.3".hours, "41 16 9".deg, 3.deg)).also(Fits::read) } + protected val M31 by lazy { download("00 42 44.3".hours, "41 16 9".deg, 3.deg).fits() } protected fun download(centerRA: Angle, centerDEC: Angle, fov: Angle): Path { val name = "$centerRA@$centerDEC@$fov".toByteArray().toByteString().md5().hex() diff --git a/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt b/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt index a0f258586..2de0810de 100644 --- a/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt +++ b/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt @@ -2,10 +2,10 @@ package nebulosa.watney.plate.solving import nebulosa.common.concurrency.cancel.CancellationToken import nebulosa.erfa.SphericalCoordinate -import nebulosa.fits.Fits import nebulosa.fits.Header import nebulosa.fits.NOAOExt import nebulosa.fits.Standard +import nebulosa.fits.fits import nebulosa.imaging.Image import nebulosa.log.debug import nebulosa.log.loggerFor @@ -47,7 +47,7 @@ data class WatneyPlateSolver( downsampleFactor: Int, timeout: Duration?, cancellationToken: CancellationToken, ): PlateSolution { - val image = image ?: Fits(path!!).also(Fits::read).use(Image::open) + val image = image ?: path!!.fits().let(Image::open) val stars = (starDetector ?: DEFAULT_STAR_DETECTOR).detect(image) LOG.debug { "detected ${stars.size} stars from the image" } @@ -287,7 +287,7 @@ data class WatneyPlateSolver( @JvmStatic private fun isValidSolution(solution: ComputedPlateSolution?): Boolean { return solution != null && solution.centerRA.isFinite() && solution.centerDEC.isFinite() - && solution.orientation.isFinite() && solution.plateConstants.isValid + && solution.orientation.isFinite() && solution.plateConstants.isValid } @JvmStatic diff --git a/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt b/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt index 5fbdd8df7..8b343dde2 100644 --- a/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt +++ b/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt @@ -2,12 +2,13 @@ import io.kotest.core.annotation.EnabledIf import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.doubles.plusOrMinus import io.kotest.matchers.shouldBe -import nebulosa.fits.Fits import nebulosa.fits.Header +import nebulosa.fits.fits import nebulosa.math.formatHMS import nebulosa.math.formatSignedDMS import nebulosa.test.NonGitHubOnlyCondition import nebulosa.wcs.WCS +import java.io.File import kotlin.random.Random // https://www.atnf.csiro.au/people/mcalabre/WCS/example_data.html @@ -53,7 +54,7 @@ class LibWCSTest : StringSpec() { } private fun readHeaderFromFits(name: String): Header { - return Fits("src/test/resources/$name.fits").use { it.readHdu()!!.header } + return File("src/test/resources/$name.fits").fits().first!!.header } companion object { From 5ad540c39139a8e70f95d61f3fe776f3b92297c3 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Wed, 14 Feb 2024 22:18:37 -0300 Subject: [PATCH 25/87] [desktop]: Auto resize window after it is open --- desktop/app/main.ts | 12 ++++++++++++ desktop/src/app/app.component.ts | 8 ++++++++ desktop/src/shared/types/app.types.ts | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/desktop/app/main.ts b/desktop/app/main.ts index 610fe5ba7..d8eb70bc2 100644 --- a/desktop/app/main.ts +++ b/desktop/app/main.ts @@ -413,6 +413,18 @@ try { return window?.isMaximized() ?? false }) + ipcMain.handle('WINDOW.RESIZE', (event, data: number) => { + const window = findWindowById(event.sender.id)?.window + + if (!window || window.isResizable()) return false + + const size = window.getSize() + window.setSize(size[0], Math.max(size[1], data)) + console.info('window resized', size[0], data) + + return true + }) + ipcMain.handle('WINDOW.CLOSE', (event, data: CloseWindow) => { if (data.id) { for (const [key, value] of browserWindows) { diff --git a/desktop/src/app/app.component.ts b/desktop/src/app/app.component.ts index ab4b79e16..451d2609d 100644 --- a/desktop/src/app/app.component.ts +++ b/desktop/src/app/app.component.ts @@ -49,6 +49,14 @@ export class AppComponent implements AfterViewInit { this.route.queryParams.subscribe(e => { this.maximizable = e.resizable === 'true' }) + + setTimeout(() => { + const size = document.getElementsByTagName('app-root')[0]?.getBoundingClientRect()?.height + + if (size) { + this.electron.send('WINDOW.RESIZE', Math.ceil(size)) + } + }, 1000) } pin() { diff --git a/desktop/src/shared/types/app.types.ts b/desktop/src/shared/types/app.types.ts index 1caabe7e4..7eec29375 100644 --- a/desktop/src/shared/types/app.types.ts +++ b/desktop/src/shared/types/app.types.ts @@ -22,7 +22,7 @@ export interface NotificationEvent extends MessageEvent { export const INTERNAL_EVENT_TYPES = [ 'DIRECTORY.OPEN', 'FILE.OPEN', 'FILE.SAVE', 'WINDOW.OPEN', 'WINDOW.CLOSE', - 'WINDOW.PIN', 'WINDOW.UNPIN', 'WINDOW.MINIMIZE', 'WINDOW.MAXIMIZE', + 'WINDOW.PIN', 'WINDOW.UNPIN', 'WINDOW.MINIMIZE', 'WINDOW.MAXIMIZE', 'WINDOW.RESIZE', 'WHEEL.RENAMED', 'LOCATION.CHANGED', 'JSON.WRITE', 'JSON.READ' ] as const From 84ce66d906626912bce3093a3346aa2745364c36 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Thu, 15 Feb 2024 12:13:30 -0300 Subject: [PATCH 26/87] [api]: Add support to ASCOM Alpaca API --- .../indi/devices/cameras/ASCOMCamera.kt | 65 ++++++++++++++++--- .../devices/cameras/ImageArrayElementType.kt | 23 +++---- .../main/kotlin/nebulosa/fits/FitsHelper.kt | 4 +- .../src/main/kotlin/nebulosa/fits/FitsIO.kt | 3 - ...oatArrayImageData.kt => FloatImageData.kt} | 14 ++-- .../src/main/kotlin/nebulosa/fits/Header.kt | 22 +++++-- .../main/kotlin/nebulosa/fits/HeaderCard.kt | 32 ++++++++- .../src/main/kotlin/nebulosa/fits/ImageHdu.kt | 6 +- .../nebulosa/fits/SeekableSourceImageData.kt | 38 +++++------ .../kotlin/nebulosa/fits/WritableHeader.kt | 8 +++ .../src/test/kotlin/ImageDataTest.kt | 63 ++++++++++++++++++ .../src/main/kotlin/nebulosa/imaging/Image.kt | 2 +- .../nebulosa/indi/device/camera/FrameType.kt | 10 +-- nebulosa-io/src/main/kotlin/nebulosa/io/Io.kt | 6 ++ 14 files changed, 227 insertions(+), 69 deletions(-) rename nebulosa-fits/src/main/kotlin/nebulosa/fits/{FloatArrayImageData.kt => FloatImageData.kt} (67%) create mode 100644 nebulosa-fits/src/test/kotlin/ImageDataTest.kt diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt index a0d82fbff..357d193d6 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt @@ -18,11 +18,20 @@ import nebulosa.indi.protocol.INDIProtocol import nebulosa.indi.protocol.PropertyState import nebulosa.io.readDoubleLe import nebulosa.io.readFloatLe +import nebulosa.math.formatHMS +import nebulosa.math.formatSignedDMS +import nebulosa.math.normalized +import nebulosa.math.toDegrees +import nebulosa.nova.position.Geoid +import nebulosa.nova.position.ICRF +import nebulosa.time.CurrentTime import okio.buffer import okio.source import java.nio.ByteBuffer import java.nio.file.Files import java.time.Duration +import java.time.LocalDate +import java.time.format.DateTimeFormatter import kotlin.math.max import kotlin.math.min @@ -189,6 +198,7 @@ data class ASCOMCamera( override fun startCapture(exposureTime: Duration) { this.exposureTime = exposureTime + service.startExposure(device.number, exposureTime.toNanos() / NANO_SECONDS, frameType == FrameType.DARK).doRequest { imageReadyWaiter.captureStarted() } @@ -600,9 +610,9 @@ data class ASCOMCamera( } private fun readImage() { - service.imageArray(device.number).execute().body()?.use { - val bytes = it.byteStream() - val metadata = ImageMetadata.from(bytes.readNBytes(44)) + service.imageArray(device.number).execute().body()?.use { body -> + val stream = body.byteStream() + val metadata = ImageMetadata.from(stream.readNBytes(44)) if (metadata.errorNumber != 0) { LOG.error("failed to read image. device={}, error={}", name, metadata.errorNumber) @@ -612,12 +622,13 @@ data class ASCOMCamera( val width = metadata.dimension1 val height = metadata.dimension2 val planes = max(1, metadata.dimension3) - val source = bytes.source().buffer() - val imageData = Array(planes) { FloatArrayImageData(width, height, FloatArray(width * height)) } - var i = 0 + val source = stream.source().buffer() + val imageData = Array(planes) { FloatImageData(width, height, FloatArray(width * height)) } for (x in 0 until width) { for (y in 0 until height) { + val idx = y * width + x + for (p in 0 until planes) { val pixel = when (metadata.imageElementType) { ImageArrayElementType.BYTE -> (source.readByte().toInt() and 0xFF) / 255f @@ -632,15 +643,51 @@ data class ASCOMCamera( ImageArrayElementType.UNKNOWN -> return } - imageData[p].data[i] = pixel + imageData[p].data[idx] = pixel } - - i++ } } + source.close() + val header = Header() header.add(Standard.SIMPLE, true) + header.add(Standard.BITPIX, -32) + header.add(Standard.NAXIS, if (planes == 3) 3 else 2) + header.add(Standard.NAXIS1, width) + header.add(Standard.NAXIS2, height) + if (planes == 3) header.add(Standard.NAXIS3, planes) + header.add(Standard.EXTEND, true) + header.add(Standard.INSTRUME, name) + header.add(Standard.EXPTIME, 0.0) // TODO + header.add(SBFitsExt.CCD_TEMP, temperature) + header.add(NOAOExt.PIXSIZEn.n(1), pixelSizeX) + header.add(NOAOExt.PIXSIZEn.n(2), pixelSizeY) + header.add(SBFitsExt.XBINNING, binX) + header.add(SBFitsExt.YBINNING, binY) + header.add(SBFitsExt.XPIXSZ, pixelSizeX * binX) + header.add(SBFitsExt.YPIXSZ, pixelSizeY * binY) + header.add("FRAME", frameType.description, "Frame Type") + header.add(SBFitsExt.IMAGETYP, "${frameType.description} Frame") + + mount?.also { + header.add(Standard.TELESCOP, it.name) + header.add(SBFitsExt.SITELAT, it.latitude.toDegrees) + header.add(SBFitsExt.SITELONG, it.longitude.toDegrees) + val center = Geoid.IERS2010.lonLat(it.longitude, it.latitude, it.elevation) + val icrf = ICRF.equatorial(it.rightAscension, it.declination, epoch = CurrentTime, center = center) + val raDec = icrf.equatorial() + header.add(SBFitsExt.OBJCTRA, raDec.longitude.normalized.formatHMS()) + header.add(SBFitsExt.OBJCTDEC, raDec.longitude.formatSignedDMS()) + header.add(Standard.RA, raDec.longitude.normalized.toDegrees) + header.add(Standard.DEC, raDec.longitude.toDegrees) + header.add(MaxImDLExt.PIERSIDE, it.pierSide.name) + header.add(Standard.EQUINOX, 2000) + header.add(Standard.DATE_OBS, LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) + header.add(Standard.COMMENT, "Generated by Nebulosa via ASCOM") + header.add(NOAOExt.GAIN, gain) + header.add("OFFSET", offset, "Offset") + } val hdu = ImageHdu(header, imageData) diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt index 63d0d0e81..c33574046 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt @@ -1,15 +1,16 @@ package nebulosa.alpaca.indi.devices.cameras -enum class ImageArrayElementType { - UNKNOWN, // 0 to 3 are values already used in the Alpaca standard - INT16, - INT32, - DOUBLE, - SINGLE, // 4 to 9 are an extension to include other numeric types - UINT64, - BYTE, - INT64, - UINT16, - UINT32, +import nebulosa.fits.Bitpix +enum class ImageArrayElementType(val bitpix: Bitpix) { + UNKNOWN(Bitpix.BYTE), // 0 to 3 are values already used in the Alpaca standard + INT16(Bitpix.SHORT), + INT32(Bitpix.INTEGER), + DOUBLE(Bitpix.DOUBLE), + SINGLE(Bitpix.FLOAT), // 4 to 9 are an extension to include other numeric types + UINT64(Bitpix.LONG), + BYTE(Bitpix.BYTE), + INT64(Bitpix.LONG), + UINT16(Bitpix.SHORT), + UINT32(Bitpix.INTEGER), } diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt index c500511b7..73a922eb1 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt @@ -19,10 +19,10 @@ inline val Header.naxis inline fun Header.naxis(n: Int) = getInt(Standard.NAXISn.n(n), 0) inline val Header.width - get() = naxis(1) + get() = getInt(Standard.NAXIS1, 0) inline val Header.height - get() = naxis(2) + get() = getInt(Standard.NAXIS2, 0) val Header.rightAscension get() = Angle(getStringOrNull(Standard.RA), isHours = true, decimalIsHours = false).takeIf { it.isFinite() } diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsIO.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsIO.kt index c1e5d70d5..d0c951efe 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsIO.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsIO.kt @@ -1,11 +1,8 @@ package nebulosa.fits import nebulosa.io.SeekableSource -import nebulosa.io.seekableSource import okio.Sink -import java.io.File import java.io.IOException -import java.nio.file.Path object FitsIO : FitsReader, FitsWriter { diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FloatArrayImageData.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FloatImageData.kt similarity index 67% rename from nebulosa-fits/src/main/kotlin/nebulosa/fits/FloatArrayImageData.kt rename to nebulosa-fits/src/main/kotlin/nebulosa/fits/FloatImageData.kt index 8fc722ad6..5da1f4d3b 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FloatArrayImageData.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FloatImageData.kt @@ -6,7 +6,7 @@ import okio.Sink import java.nio.ByteBuffer @Suppress("ArrayInDataClass") -data class FloatArrayImageData( +data class FloatImageData( override val width: Int, override val height: Int, @JvmField val data: FloatArray, @@ -19,22 +19,22 @@ data class FloatArrayImageData( val stride = ByteBuffer.allocate(strideSizeInBytes) repeat(height) { - val offset = it * width + var offset = it * width stride.clear() - for (i in offset until offset + width) stride.putFloat(data[i]) + repeat(width) { stride.putFloat(data[offset++]) } stride.flip() block(stride) } } override fun writeTo(sink: Sink): Long { - return Buffer().use { buffer -> + return Buffer().use { b -> var byteCount = 0L repeat(height) { - val offset = it * width - for (i in offset until offset + width) buffer.writeFloat(data[i]) - byteCount += buffer.readAll(sink) + var offset = it * width + repeat(width) { b.writeFloat(data[offset++]) } + byteCount += b.readAll(sink) } byteCount diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/Header.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/Header.kt index f35e07969..0f5132f13 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/Header.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/Header.kt @@ -77,11 +77,11 @@ open class Header internal constructor(@JvmField internal val cards: LinkedList< override fun add(key: FitsHeader, value: Boolean): HeaderCard { checkType(key, ValueType.LOGICAL) - val card = HeaderCard.create(key, value) - val index = cards.indexOfFirst { it.key == key.key } - if (index >= 0) cards[index] = card - else cards.add(card) - return card + return HeaderCard.create(key, value).also(::add) + } + + override fun add(key: String, value: Boolean, comment: String): HeaderCard { + return HeaderCard.create(key, value, comment).also(::add) } override fun add(key: FitsHeader, value: Int): HeaderCard { @@ -89,16 +89,28 @@ open class Header internal constructor(@JvmField internal val cards: LinkedList< return HeaderCard.create(key, value).also(::add) } + override fun add(key: String, value: Int, comment: String): HeaderCard { + return HeaderCard.create(key, value, comment).also(::add) + } + override fun add(key: FitsHeader, value: Double): HeaderCard { checkType(key, ValueType.REAL) return HeaderCard.create(key, value).also(::add) } + override fun add(key: String, value: Double, comment: String): HeaderCard { + return HeaderCard.create(key, value, comment).also(::add) + } + override fun add(key: FitsHeader, value: String): HeaderCard { checkType(key, ValueType.STRING) return HeaderCard.create(key, value).also(::add) } + override fun add(key: String, value: String, comment: String): HeaderCard { + return HeaderCard.create(key, value, comment).also(::add) + } + override fun add(card: HeaderCard) { if (!card.isKeyValuePair) cards.add(card) else { diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/HeaderCard.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/HeaderCard.kt index d913d85a3..27d5e392f 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/HeaderCard.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/HeaderCard.kt @@ -31,7 +31,7 @@ data class HeaderCard( val isDecimalType get() = DECIMAL_TYPES.any { it === type } - || BigDecimal::class.java.isAssignableFrom(type) + || BigDecimal::class.java.isAssignableFrom(type) val isIntegerType get() = INTEGET_TYPES.any { it === type } @@ -176,6 +176,36 @@ data class HeaderCard( return HeaderCard(header.key, value, header.comment, String::class.javaObjectType) } + @JvmStatic + fun create(key: String, value: Boolean, comment: String = ""): HeaderCard { + return HeaderCard(key, if (value) "T" else "F", comment, Boolean::class.javaPrimitiveType!!) + } + + @JvmStatic + fun create(key: String, value: Int, comment: String = ""): HeaderCard { + return HeaderCard(key, "$value", comment, Int::class.javaPrimitiveType!!) + } + + @JvmStatic + fun create(key: String, value: Long, comment: String = ""): HeaderCard { + return HeaderCard(key, "$value", comment, Long::class.javaPrimitiveType!!) + } + + @JvmStatic + fun create(key: String, value: Float, comment: String = ""): HeaderCard { + return HeaderCard(key, "$value", comment, Float::class.javaPrimitiveType!!) + } + + @JvmStatic + fun create(key: String, value: Double, comment: String = ""): HeaderCard { + return HeaderCard(key, "$value", comment, Double::class.javaPrimitiveType!!) + } + + @JvmStatic + fun create(key: String, value: String, comment: String = ""): HeaderCard { + return HeaderCard(key, value, comment, String::class.javaObjectType) + } + @JvmStatic internal fun isHierarchKey(key: String): Boolean { return key.uppercase().startsWith(HIERARCH_WITH_DOT) diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/ImageHdu.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/ImageHdu.kt index 4023f383d..43fd30a06 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/ImageHdu.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/ImageHdu.kt @@ -9,10 +9,8 @@ data class ImageHdu( override var data: Array = emptyArray(), ) : Hdu { - val width = header.getInt(Standard.NAXIS1, 0) - - val height = header.getInt(Standard.NAXIS2, 0) - + val width = header.width + val height = header.height val bitpix = Bitpix.from(header) override fun read(source: SeekableSource) { diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/SeekableSourceImageData.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/SeekableSourceImageData.kt index 2e4a30fc4..85f0ca77e 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/SeekableSourceImageData.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/SeekableSourceImageData.kt @@ -1,57 +1,53 @@ package nebulosa.fits -import nebulosa.io.Seekable +import nebulosa.io.SeekableSource import nebulosa.io.sink import nebulosa.io.transferFully import okio.Buffer import okio.Sink -import okio.Source import java.nio.ByteBuffer data class SeekableSourceImageData( - private val source: Seekable, + private val source: SeekableSource, private val position: Long, override val width: Int, override val height: Int, override val bitpix: Bitpix, ) : ImageData { - override fun read(block: (ByteBuffer) -> Unit) { - require(source is Source) - - val strideSizeInBytes = (width * bitpix.byteSize).toLong() + private val strideSizeInBytes = (width * bitpix.byteSize).toLong() - val buffer = Buffer() + override fun read(block: (ByteBuffer) -> Unit) { val data = ByteArray(strideSizeInBytes.toInt()) val sink = data.sink() synchronized(source) { source.seek(position) - repeat(height) { - sink.seek(0L) + Buffer().use { b -> + repeat(height) { + sink.seek(0L) - buffer.transferFully(source, sink, strideSizeInBytes) - block(ByteBuffer.wrap(data)) - buffer.clear() + b.transferFully(source, sink, strideSizeInBytes) + block(ByteBuffer.wrap(data)) + b.clear() + } } } } override fun writeTo(sink: Sink): Long { - require(source is Source) - - val buffer = Buffer() - val strideSizeInBytes = (width * bitpix.byteSize).toLong() var byteCount = 0L return synchronized(source) { source.seek(position) - repeat(height) { - buffer.transferFully(source, sink, strideSizeInBytes) - buffer.clear() - byteCount += strideSizeInBytes + Buffer().use { b -> + repeat(height) { + b.transferFully(source, sink, strideSizeInBytes) + b.clear() + byteCount += strideSizeInBytes + } } byteCount diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/WritableHeader.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/WritableHeader.kt index 41abb4210..288459691 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/WritableHeader.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/WritableHeader.kt @@ -12,6 +12,14 @@ interface WritableHeader { fun add(key: FitsHeader, value: String): HeaderCard + fun add(key: String, value: Boolean, comment: String = ""): HeaderCard + + fun add(key: String, value: Int, comment: String = ""): HeaderCard + + fun add(key: String, value: Double, comment: String = ""): HeaderCard + + fun add(key: String, value: String, comment: String = ""): HeaderCard + fun add(card: HeaderCard) fun delete(key: FitsHeader): Boolean diff --git a/nebulosa-fits/src/test/kotlin/ImageDataTest.kt b/nebulosa-fits/src/test/kotlin/ImageDataTest.kt new file mode 100644 index 000000000..77e75b901 --- /dev/null +++ b/nebulosa-fits/src/test/kotlin/ImageDataTest.kt @@ -0,0 +1,63 @@ +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.floats.shouldBeExactly +import io.kotest.matchers.ints.shouldBeExactly +import nebulosa.fits.Bitpix +import nebulosa.fits.FloatImageData +import nebulosa.fits.SeekableSourceImageData +import nebulosa.io.sink +import nebulosa.io.source +import java.nio.ByteBuffer + +class ImageDataTest : StringSpec() { + + init { + "float:read" { + val input = FloatArray(100) { it.toFloat() } + val data = FloatImageData(10, 10, input) + + var i = 0 + + data.read { b -> + repeat(10) { b.getFloat() shouldBeExactly input[i++] } + } + + i shouldBeExactly 100 + } + "float:write" { + val input = FloatArray(100) { it.toFloat() } + val data = FloatImageData(10, 10, input) + val output = ByteArray(100 * 4) + + data.writeTo(output.sink()) + + val buffer = ByteBuffer.wrap(output) + + repeat(100) { + buffer.getFloat() shouldBeExactly input[it] + } + } + "seekable source:read" { + val input = ByteArray(100) { it.toByte() } + val data = SeekableSourceImageData(input.source(), 0L, 10, 10, Bitpix.BYTE) + + var i = 0 + + data.read { b -> + repeat(10) { b.get().toInt() shouldBeExactly input[i++].toInt() } + } + + i shouldBeExactly 100 + } + "seekable source:write" { + val input = ByteArray(100) { it.toByte() } + val data = SeekableSourceImageData(input.source(), 0L, 10, 10, Bitpix.BYTE) + val output = ByteArray(input.size) + + data.writeTo(output.sink()) + + repeat(output.size) { + output[it].toInt() shouldBeExactly input[it].toInt() + } + } + } +} diff --git a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Image.kt b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Image.kt index 5d8ce9a55..63321133e 100644 --- a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Image.kt +++ b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Image.kt @@ -207,7 +207,7 @@ class Image( } fun hdu(): Hdu { - val data = Array(numberOfChannels) { FloatArrayImageData(width, height, this.data[it]) } + val data = Array(numberOfChannels) { FloatImageData(width, height, this.data[it]) } return ImageHdu(header, data) } diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/FrameType.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/FrameType.kt index 3688079b8..f6bb25e2b 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/FrameType.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/FrameType.kt @@ -1,8 +1,8 @@ package nebulosa.indi.device.camera -enum class FrameType { - LIGHT, - DARK, - FLAT, - BIAS, +enum class FrameType(@JvmField val description: String) { + LIGHT("Light"), + DARK("Dark"), + FLAT("Flat"), + BIAS("Bias"), } diff --git a/nebulosa-io/src/main/kotlin/nebulosa/io/Io.kt b/nebulosa-io/src/main/kotlin/nebulosa/io/Io.kt index ebe3536d8..73893ff4a 100644 --- a/nebulosa-io/src/main/kotlin/nebulosa/io/Io.kt +++ b/nebulosa-io/src/main/kotlin/nebulosa/io/Io.kt @@ -158,6 +158,9 @@ inline fun Buffer.read(source: Source, byteCount: Long, block: (Buffer) -> T } } +/** + * Reads [byteCount] bytes from [source] to this buffer. + */ fun Buffer.readFully(source: Source, byteCount: Long) { var remainingCount = byteCount @@ -168,6 +171,9 @@ fun Buffer.readFully(source: Source, byteCount: Long) { } } +/** + * Transfers [byteCount] bytes from [source] to [sink] using this buffer as intermediate. + */ fun Buffer.transferFully(source: Source, sink: Sink, byteCount: Long) { var remainingCount = byteCount From 62daad96041c94a9d7d5e96b1a35bdd693de55de Mon Sep 17 00:00:00 2001 From: tiagohm Date: Thu, 15 Feb 2024 21:59:40 -0300 Subject: [PATCH 27/87] [api]: Add support to ASCOM Alpaca API --- .../indi/devices/cameras/ASCOMCamera.kt | 26 +++++++------------ .../devices/cameras/ImageArrayElementType.kt | 2 +- .../kotlin/nebulosa/fits/FloatImageData.kt | 2 +- .../nebulosa/imaging/Float8bitsDataBuffer.kt | 3 ++- .../src/main/kotlin/nebulosa/imaging/Image.kt | 3 +-- 5 files changed, 15 insertions(+), 21 deletions(-) diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt index 357d193d6..78c3886db 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt @@ -28,7 +28,6 @@ import nebulosa.time.CurrentTime import okio.buffer import okio.source import java.nio.ByteBuffer -import java.nio.file.Files import java.time.Duration import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -145,7 +144,6 @@ data class ASCOMCamera( @Volatile private var mount: Mount? = null private val imageReadyWaiter = ImageReadyWaiter() - private val savedImagePath = Files.createTempFile(name, ".fits") init { refresh(0L) @@ -623,27 +621,23 @@ data class ASCOMCamera( val height = metadata.dimension2 val planes = max(1, metadata.dimension3) val source = stream.source().buffer() - val imageData = Array(planes) { FloatImageData(width, height, FloatArray(width * height)) } + val data = Array(planes) { FloatImageData(width, height) } for (x in 0 until width) { for (y in 0 until height) { val idx = y * width + x for (p in 0 until planes) { - val pixel = when (metadata.imageElementType) { - ImageArrayElementType.BYTE -> (source.readByte().toInt() and 0xFF) / 255f - ImageArrayElementType.INT16, - ImageArrayElementType.UINT16 -> (source.readShortLe().toInt() + 32768) / 65535f - ImageArrayElementType.INT32, - ImageArrayElementType.UINT32 -> ((source.readIntLe().toLong() + 2147483648) / 4294967295.0).toFloat() - ImageArrayElementType.INT64, - ImageArrayElementType.UINT64 -> return - ImageArrayElementType.SINGLE -> source.readFloatLe() - ImageArrayElementType.DOUBLE -> source.readDoubleLe().toFloat() - ImageArrayElementType.UNKNOWN -> return + val pixel = when (metadata.imageElementType.bitpix) { + Bitpix.BYTE -> (source.readByte().toInt() and 0xFF) / 255f + Bitpix.SHORT -> (source.readShortLe().toInt() + 32768) / 65535f + Bitpix.INTEGER -> ((source.readIntLe().toLong() + 2147483648) / 4294967295.0).toFloat() + Bitpix.FLOAT -> source.readFloatLe() + Bitpix.DOUBLE -> source.readDoubleLe().toFloat() + Bitpix.LONG -> return } - imageData[p].data[idx] = pixel + data[p].data[idx] = pixel } } } @@ -689,7 +683,7 @@ data class ASCOMCamera( header.add("OFFSET", offset, "Offset") } - val hdu = ImageHdu(header, imageData) + val hdu = ImageHdu(header, data) val fits = Fits() fits.add(hdu) diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt index c33574046..dc8c69111 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt @@ -2,7 +2,7 @@ package nebulosa.alpaca.indi.devices.cameras import nebulosa.fits.Bitpix -enum class ImageArrayElementType(val bitpix: Bitpix) { +enum class ImageArrayElementType(@JvmField val bitpix: Bitpix) { UNKNOWN(Bitpix.BYTE), // 0 to 3 are values already used in the Alpaca standard INT16(Bitpix.SHORT), INT32(Bitpix.INTEGER), diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FloatImageData.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FloatImageData.kt index 5da1f4d3b..0c62de7fc 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FloatImageData.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FloatImageData.kt @@ -9,7 +9,7 @@ import java.nio.ByteBuffer data class FloatImageData( override val width: Int, override val height: Int, - @JvmField val data: FloatArray, + @JvmField val data: FloatArray = FloatArray(width * height), ) : ImageData { override val bitpix = Bitpix.FLOAT diff --git a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Float8bitsDataBuffer.kt b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Float8bitsDataBuffer.kt index 6613d5a6a..3b3cb3a53 100644 --- a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Float8bitsDataBuffer.kt +++ b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Float8bitsDataBuffer.kt @@ -2,7 +2,8 @@ package nebulosa.imaging import java.awt.image.DataBuffer -class Float8bitsDataBuffer( +@Suppress("ArrayInDataClass") +data class Float8bitsDataBuffer( @JvmField val mono: Boolean, @JvmField val r: FloatArray, // or gray. @JvmField val g: FloatArray = r, diff --git a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Image.kt b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Image.kt index 63321133e..405896762 100644 --- a/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Image.kt +++ b/nebulosa-imaging/src/main/kotlin/nebulosa/imaging/Image.kt @@ -207,8 +207,7 @@ class Image( } fun hdu(): Hdu { - val data = Array(numberOfChannels) { FloatImageData(width, height, this.data[it]) } - return ImageHdu(header, data) + return ImageHdu(header, Array(numberOfChannels) { FloatImageData(width, height, data[it]) }) } /** From f3e87928e138068b7b17a9d516de4fbd9d724c1b Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 17 Feb 2024 12:06:59 -0300 Subject: [PATCH 28/87] [api]: Show notification when satellites are updated --- .../kotlin/nebulosa/api/atlas/SatelliteUpdateTask.kt | 10 ++++++++++ .../kotlin/nebulosa/api/atlas/SkyAtlasUpdateTask.kt | 10 +++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/api/src/main/kotlin/nebulosa/api/atlas/SatelliteUpdateTask.kt b/api/src/main/kotlin/nebulosa/api/atlas/SatelliteUpdateTask.kt index 1d61fd45d..c43c00416 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/SatelliteUpdateTask.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/SatelliteUpdateTask.kt @@ -1,5 +1,7 @@ package nebulosa.api.atlas +import nebulosa.api.messages.MessageService +import nebulosa.api.notifications.NotificationEvent import nebulosa.api.preferences.PreferenceService import nebulosa.log.loggerFor import okhttp3.OkHttpClient @@ -14,8 +16,15 @@ class SatelliteUpdateTask( private val httpClient: OkHttpClient, private val preferenceService: PreferenceService, private val satelliteRepository: SatelliteRepository, + private val messageService: MessageService, ) : Runnable { + data class UpdateFinished(val numberOfSatellites: Int) : NotificationEvent { + + override val type = "SATELLITE.UPDATE_FINISHED" + override val body = "%d satellites was updated".format(numberOfSatellites) + } + @Scheduled(fixedDelay = UPDATE_INTERVAL, timeUnit = TimeUnit.MILLISECONDS) override fun run() { checkIsOutOfDateAndUpdate() @@ -57,6 +66,7 @@ class SatelliteUpdateTask( return satelliteRepository .save(data.values) .also { LOG.info("{} satellites updated", it.size) } + .also { messageService.sendMessage(UpdateFinished(it.size)) } .isNotEmpty() } diff --git a/api/src/main/kotlin/nebulosa/api/atlas/SkyAtlasUpdateTask.kt b/api/src/main/kotlin/nebulosa/api/atlas/SkyAtlasUpdateTask.kt index 8a1f178cf..8eced252c 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/SkyAtlasUpdateTask.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/SkyAtlasUpdateTask.kt @@ -22,13 +22,13 @@ class SkyAtlasUpdateTask( data object UpdateStarted : NotificationEvent { override val type = "SKY_ATLAS.UPDATE_STARTED" - override val body = "Sky Atlas is being updated" + override val body = "Sky Atlas database is being updated" } data object UpdateFinished : NotificationEvent { override val type = "SKY_ATLAS.UPDATE_FINISHED" - override val body = "Sky Atlas was updated" + override val body = "Sky Atlas database was updated" } @Scheduled(fixedDelay = Long.MAX_VALUE, timeUnit = TimeUnit.SECONDS) @@ -40,7 +40,7 @@ class SkyAtlasUpdateTask( val newestVersion = response.body!!.string() if (newestVersion != preferenceService.getText(VERSION_KEY) || simbadEntityRepository.isEmpty()) { - LOG.info("Sky Atlas is out of date. Downloading...") + LOG.info("Sky Atlas database is out of date. Downloading...") messageService.sendMessage(UpdateStarted) @@ -71,9 +71,9 @@ class SkyAtlasUpdateTask( preferenceService.putText(VERSION_KEY, newestVersion) messageService.sendMessage(UpdateFinished) - LOG.info("Sky Atlas was updated. version={}, size={}", newestVersion, simbadEntityRepository.size) + LOG.info("Sky Atlas database was updated. version={}, size={}", newestVersion, simbadEntityRepository.size) } else { - LOG.info("Sky Atlas is up to date. version={}, size={}", newestVersion, simbadEntityRepository.size) + LOG.info("Sky Atlas database is up to date. version={}, size={}", newestVersion, simbadEntityRepository.size) } } } From f77e31b2f0acc42972ebdf5ece2ecd5cb97d8552 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 17 Feb 2024 12:45:57 -0300 Subject: [PATCH 29/87] [api]: Fix FITS read --- .../api/alignment/polar/tppa/TPPAStep.kt | 2 +- .../calibration/CalibrationFrameService.kt | 13 ++-- .../nebulosa/api/framing/FramingService.kt | 2 +- .../kotlin/nebulosa/api/image/ImageBucket.kt | 2 +- .../api/wizard/flat/FlatWizardStep.kt | 2 +- .../main/kotlin/nebulosa/fits/FitsHelper.kt | 7 ++- .../src/main/kotlin/nebulosa/fits/FitsPath.kt | 34 ++++++++++ .../src/test/kotlin/FitsWriteTest.kt | 2 +- .../kotlin/nebulosa/test/FitsStringSpec.kt | 63 +++++++++---------- .../watney/plate/solving/WatneyPlateSolver.kt | 2 +- nebulosa-wcs/src/test/kotlin/LibWCSTest.kt | 3 +- 11 files changed, 82 insertions(+), 50 deletions(-) create mode 100644 nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsPath.kt diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt index 251c9244c..02dd5ead7 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt @@ -120,7 +120,7 @@ data class TPPAStep( if (!cancellationToken.isCancelled) { val savedPath = cameraExposureStep.savedPath ?: return StepResult.FINISHED - image = savedPath.fits().let { image?.load(it, false) ?: Image.open(it, false) } + image = savedPath.fits().use { image?.load(it, false) ?: Image.open(it, false) } val radius = if (mount == null) 0.0 else ThreePointPolarAlignment.DEFAULT_RADIUS diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt index 0a62cba5f..a217cac8e 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt @@ -8,7 +8,6 @@ import nebulosa.imaging.algorithms.transformation.correction.FlatCorrection import nebulosa.indi.device.camera.FrameType import nebulosa.log.loggerFor import org.springframework.stereotype.Service -import java.io.File import java.nio.file.Path import java.util.* import kotlin.io.path.isDirectory @@ -35,7 +34,7 @@ class CalibrationFrameService( var calibrationImage = Image(transformedImage.width, transformedImage.height, Header.EMPTY, transformedImage.mono) if (biasFrame != null) { - calibrationImage = File(biasFrame.path!!).fits().let(calibrationImage::load)!! + calibrationImage = biasFrame.path!!.fits().use(calibrationImage::load)!! transformedImage = transformedImage.transform(BiasSubtraction(calibrationImage)) LOG.info("bias frame subtraction applied. frame={}", biasFrame) } else { @@ -46,7 +45,7 @@ class CalibrationFrameService( } if (darkFrame != null) { - calibrationImage = File(darkFrame.path!!).fits().let(calibrationImage::load)!! + calibrationImage = darkFrame.path!!.fits().use(calibrationImage::load)!! transformedImage = transformedImage.transform(DarkSubtraction(calibrationImage)) LOG.info("dark frame subtraction applied. frame={}", darkFrame) } else { @@ -57,7 +56,7 @@ class CalibrationFrameService( } if (flatFrame != null) { - calibrationImage = File(flatFrame.path!!).fits().let(calibrationImage::load)!! + calibrationImage = flatFrame.path!!.fits().use(calibrationImage::load)!! transformedImage = transformedImage.transform(FlatCorrection(calibrationImage)) LOG.info("flat frame correction applied. frame={}", flatFrame) } else { @@ -98,9 +97,9 @@ class CalibrationFrameService( calibrationFrameRepository.delete(camera, "$file") try { - file.fits().also { fits -> - val (header) = fits.filterIsInstance().firstOrNull() ?: return@also - val frameType = header.frameType?.takeIf { it != FrameType.LIGHT } ?: return@also + file.fits().use { fits -> + val (header) = fits.filterIsInstance().firstOrNull() ?: return@use + val frameType = header.frameType?.takeIf { it != FrameType.LIGHT } ?: return@use val exposureTime = if (frameType == FrameType.DARK) header.exposureTimeInMicroseconds else 0L val temperature = if (frameType == FrameType.DARK) header.temperature else 999.0 diff --git a/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt b/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt index 64ab26ea2..7250870eb 100644 --- a/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt +++ b/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt @@ -32,7 +32,7 @@ class FramingService(private val hips2FitsService: Hips2FitsService) { ).execute().body() ?: return null responseBody.use { it.byteStream().transferAndCloseOutput(DEFAULT_PATH.outputStream()) } - val image = DEFAULT_PATH.fits().let(Image::open) + val image = DEFAULT_PATH.fits().use(Image::open) val solution = PlateSolution.from(image.header) LOG.info("framing file loaded. calibration={}", solution) return Triple(image, solution, DEFAULT_PATH) diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt b/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt index 6b484128c..cc73e3e7f 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt @@ -27,7 +27,7 @@ class ImageBucket { fun open(path: Path, debayer: Boolean = true, solution: PlateSolution? = null, force: Boolean = false): Image { val openedImage = this[path] if (openedImage != null && !force) return openedImage.first - val image = path.fits().let { openedImage?.first?.load(it) ?: Image.open(it, debayer) } + val image = path.fits().use { openedImage?.first?.load(it) ?: Image.open(it, debayer) } put(path, image, solution ?: PlateSolution.from(image.header)) return image } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt index 9b4eba2ec..c57d04d34 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt @@ -86,7 +86,7 @@ data class FlatWizardStep( val savedPath = cameraExposureStep.savedPath if (!stopped && savedPath != null) { - image = savedPath.fits().let { image?.load(it, false) ?: Image.open(it, false) } + image = savedPath.fits().use { image?.load(it, false) ?: Image.open(it, false) } val statistics = STATISTICS.compute(image!!) LOG.info("flat frame captured. duration={}, statistics={}", exposureTime, statistics) diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt index 73a922eb1..50dd6a35c 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt @@ -3,7 +3,6 @@ package nebulosa.fits import nebulosa.io.SeekableSource -import nebulosa.io.seekableSource import nebulosa.math.Angle import nebulosa.math.deg import java.io.File @@ -80,6 +79,8 @@ inline val Header.instrument inline fun SeekableSource.fits() = Fits().also { it.read(this) } -inline fun Path.fits() = toFile().fits() +inline fun String.fits() = FitsPath(this).also(FitsPath::read) -inline fun File.fits() = Fits().also { seekableSource().use(it::read) } +inline fun Path.fits() = FitsPath(this).also(FitsPath::read) + +inline fun File.fits() = FitsPath(this).also(FitsPath::read) diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsPath.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsPath.kt new file mode 100644 index 000000000..84d4d5704 --- /dev/null +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsPath.kt @@ -0,0 +1,34 @@ +package nebulosa.fits + +import nebulosa.io.seekableSink +import nebulosa.io.seekableSource +import java.io.Closeable +import java.io.File +import java.nio.file.Path + +class FitsPath(path: Path) : Fits(), Closeable { + + private val source = path.seekableSource() + private val sink = path.seekableSink() + + constructor(file: File) : this(file.toPath()) + + constructor(path: String) : this(Path.of(path)) + + fun read() { + return read(source) + } + + fun readHdu(): Hdu<*>? { + return readHdu(source) + } + + fun writeTo() { + writeTo(sink) + } + + override fun close() { + source.close() + sink.close() + } +} diff --git a/nebulosa-fits/src/test/kotlin/FitsWriteTest.kt b/nebulosa-fits/src/test/kotlin/FitsWriteTest.kt index aca6aba6f..95b084b0c 100644 --- a/nebulosa-fits/src/test/kotlin/FitsWriteTest.kt +++ b/nebulosa-fits/src/test/kotlin/FitsWriteTest.kt @@ -15,7 +15,7 @@ class FitsWriteTest : FitsStringSpec() { hdu0.write(data.sink()) data.toByteString(2880, 66240).md5().hex() shouldBe "e1735e21c94dc49885fabc429406e573" - val fits = data.source().fits() + val fits = data.source().use { it.fits() } val hdu1 = fits.filterIsInstance().first() hdu0.header shouldBe hdu1.header diff --git a/nebulosa-test/src/main/kotlin/nebulosa/test/FitsStringSpec.kt b/nebulosa-test/src/main/kotlin/nebulosa/test/FitsStringSpec.kt index c03ce58de..151665d1d 100644 --- a/nebulosa-test/src/main/kotlin/nebulosa/test/FitsStringSpec.kt +++ b/nebulosa-test/src/main/kotlin/nebulosa/test/FitsStringSpec.kt @@ -7,7 +7,6 @@ import okhttp3.OkHttpClient import okhttp3.Request import okio.ByteString.Companion.toByteString import java.awt.image.BufferedImage -import java.io.File import java.nio.file.Path import java.util.concurrent.TimeUnit import java.util.zip.ZipInputStream @@ -19,38 +18,38 @@ abstract class FitsStringSpec : StringSpec() { protected val FITS_DIR = "../data/fits" - protected val NGC3344_COLOR_8 by lazy { File("$FITS_DIR/NGC3344.Color.8.fits").fits() } - protected val NGC3344_COLOR_16 by lazy { File("$FITS_DIR/NGC3344.Color.16.fits").fits() } - protected val NGC3344_COLOR_32 by lazy { File("$FITS_DIR/NGC3344.Color.32.fits").fits() } - protected val NGC3344_COLOR_F32 by lazy { File("$FITS_DIR/NGC3344.Color.F32.fits").fits() } - protected val NGC3344_COLOR_F64 by lazy { File("$FITS_DIR/NGC3344.Color.F64.fits").fits() } - protected val NGC3344_MONO_8 by lazy { File("$FITS_DIR/NGC3344.Mono.8.fits").fits() } - protected val NGC3344_MONO_16 by lazy { File("$FITS_DIR/NGC3344.Mono.16.fits").fits() } - protected val NGC3344_MONO_32 by lazy { File("$FITS_DIR/NGC3344.Mono.32.fits").fits() } - protected val NGC3344_MONO_F32 by lazy { File("$FITS_DIR/NGC3344.Mono.F32.fits").fits() } - protected val NGC3344_MONO_F64 by lazy { File("$FITS_DIR/NGC3344.Mono.F64.fits").fits() } + protected val NGC3344_COLOR_8 by lazy { "$FITS_DIR/NGC3344.Color.8.fits".fits() } + protected val NGC3344_COLOR_16 by lazy { "$FITS_DIR/NGC3344.Color.16.fits".fits() } + protected val NGC3344_COLOR_32 by lazy { "$FITS_DIR/NGC3344.Color.32.fits".fits() } + protected val NGC3344_COLOR_F32 by lazy { "$FITS_DIR/NGC3344.Color.F32.fits".fits() } + protected val NGC3344_COLOR_F64 by lazy { "$FITS_DIR/NGC3344.Color.F64.fits".fits() } + protected val NGC3344_MONO_8 by lazy { "$FITS_DIR/NGC3344.Mono.8.fits".fits() } + protected val NGC3344_MONO_16 by lazy { "$FITS_DIR/NGC3344.Mono.16.fits".fits() } + protected val NGC3344_MONO_32 by lazy { "$FITS_DIR/NGC3344.Mono.32.fits".fits() } + protected val NGC3344_MONO_F32 by lazy { "$FITS_DIR/NGC3344.Mono.F32.fits".fits() } + protected val NGC3344_MONO_F64 by lazy { "$FITS_DIR/NGC3344.Mono.F64.fits".fits() } protected val M6707HH by lazy { download("M6707HH.fits", ASTROPY_PHOTOMETRY_URL).fits() } - protected val STAR_FOCUS_1 by lazy { File("$FITS_DIR/STAR_FOCUS_1.fits").fits() } - protected val STAR_FOCUS_2 by lazy { File("$FITS_DIR/STAR_FOCUS_2.fits").fits() } - protected val STAR_FOCUS_3 by lazy { File("$FITS_DIR/STAR_FOCUS_3.fits").fits() } - protected val STAR_FOCUS_4 by lazy { File("$FITS_DIR/STAR_FOCUS_4.fits").fits() } - protected val STAR_FOCUS_5 by lazy { File("$FITS_DIR/STAR_FOCUS_5.fits").fits() } - protected val STAR_FOCUS_6 by lazy { File("$FITS_DIR/STAR_FOCUS_6.fits").fits() } - protected val STAR_FOCUS_7 by lazy { File("$FITS_DIR/STAR_FOCUS_7.fits").fits() } - protected val STAR_FOCUS_8 by lazy { File("$FITS_DIR/STAR_FOCUS_8.fits").fits() } - protected val STAR_FOCUS_9 by lazy { File("$FITS_DIR/STAR_FOCUS_9.fits").fits() } - protected val STAR_FOCUS_10 by lazy { File("$FITS_DIR/STAR_FOCUS_10.fits").fits() } - protected val STAR_FOCUS_11 by lazy { File("$FITS_DIR/STAR_FOCUS_11.fits").fits() } - protected val STAR_FOCUS_12 by lazy { File("$FITS_DIR/STAR_FOCUS_12.fits").fits() } - protected val STAR_FOCUS_13 by lazy { File("$FITS_DIR/STAR_FOCUS_13.fits").fits() } - protected val STAR_FOCUS_14 by lazy { File("$FITS_DIR/STAR_FOCUS_14.fits").fits() } - protected val STAR_FOCUS_15 by lazy { File("$FITS_DIR/STAR_FOCUS_15.fits").fits() } - protected val STAR_FOCUS_16 by lazy { File("$FITS_DIR/STAR_FOCUS_16.fits").fits() } - protected val STAR_FOCUS_17 by lazy { File("$FITS_DIR/STAR_FOCUS_17.fits").fits() } - protected val UNCALIBRATED by lazy { File("$FITS_DIR/UNCALIBRATED.fits").fits() } - protected val DARK by lazy { File("$FITS_DIR/DARK.fits").fits() } - protected val FLAT by lazy { File("$FITS_DIR/FLAT.fits").fits() } - protected val BIAS by lazy { File("$FITS_DIR/BIAS.fits").fits() } + protected val STAR_FOCUS_1 by lazy { "$FITS_DIR/STAR_FOCUS_1.fits".fits() } + protected val STAR_FOCUS_2 by lazy { "$FITS_DIR/STAR_FOCUS_2.fits".fits() } + protected val STAR_FOCUS_3 by lazy { "$FITS_DIR/STAR_FOCUS_3.fits".fits() } + protected val STAR_FOCUS_4 by lazy { "$FITS_DIR/STAR_FOCUS_4.fits".fits() } + protected val STAR_FOCUS_5 by lazy { "$FITS_DIR/STAR_FOCUS_5.fits".fits() } + protected val STAR_FOCUS_6 by lazy { "$FITS_DIR/STAR_FOCUS_6.fits".fits() } + protected val STAR_FOCUS_7 by lazy { "$FITS_DIR/STAR_FOCUS_7.fits".fits() } + protected val STAR_FOCUS_8 by lazy { "$FITS_DIR/STAR_FOCUS_8.fits".fits() } + protected val STAR_FOCUS_9 by lazy { "$FITS_DIR/STAR_FOCUS_9.fits".fits() } + protected val STAR_FOCUS_10 by lazy { "$FITS_DIR/STAR_FOCUS_10.fits".fits() } + protected val STAR_FOCUS_11 by lazy { "$FITS_DIR/STAR_FOCUS_11.fits".fits() } + protected val STAR_FOCUS_12 by lazy { "$FITS_DIR/STAR_FOCUS_12.fits".fits() } + protected val STAR_FOCUS_13 by lazy { "$FITS_DIR/STAR_FOCUS_13.fits".fits() } + protected val STAR_FOCUS_14 by lazy { "$FITS_DIR/STAR_FOCUS_14.fits".fits() } + protected val STAR_FOCUS_15 by lazy { "$FITS_DIR/STAR_FOCUS_15.fits".fits() } + protected val STAR_FOCUS_16 by lazy { "$FITS_DIR/STAR_FOCUS_16.fits".fits() } + protected val STAR_FOCUS_17 by lazy { "$FITS_DIR/STAR_FOCUS_17.fits".fits() } + protected val UNCALIBRATED by lazy { "$FITS_DIR/UNCALIBRATED.fits".fits() } + protected val DARK by lazy { "$FITS_DIR/DARK.fits".fits() } + protected val FLAT by lazy { "$FITS_DIR/FLAT.fits".fits() } + protected val BIAS by lazy { "$FITS_DIR/BIAS.fits".fits() } protected fun BufferedImage.save(name: String): Pair { val path = Path.of("src", "test", "resources", "saved", "$name.png").createParentDirectories() diff --git a/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt b/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt index 2de0810de..9df007b95 100644 --- a/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt +++ b/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt @@ -47,7 +47,7 @@ data class WatneyPlateSolver( downsampleFactor: Int, timeout: Duration?, cancellationToken: CancellationToken, ): PlateSolution { - val image = image ?: path!!.fits().let(Image::open) + val image = image ?: path!!.fits().use(Image::open) val stars = (starDetector ?: DEFAULT_STAR_DETECTOR).detect(image) LOG.debug { "detected ${stars.size} stars from the image" } diff --git a/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt b/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt index 8b343dde2..ef6b7db4e 100644 --- a/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt +++ b/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt @@ -8,7 +8,6 @@ import nebulosa.math.formatHMS import nebulosa.math.formatSignedDMS import nebulosa.test.NonGitHubOnlyCondition import nebulosa.wcs.WCS -import java.io.File import kotlin.random.Random // https://www.atnf.csiro.au/people/mcalabre/WCS/example_data.html @@ -54,7 +53,7 @@ class LibWCSTest : StringSpec() { } private fun readHeaderFromFits(name: String): Header { - return File("src/test/resources/$name.fits").fits().first!!.header + return "src/test/resources/$name.fits".fits().use { it.first!!.header } } companion object { From 6645b5d5bf12eea6e7c3ba6d10152dfd133ab7b4 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 17 Feb 2024 15:41:31 -0300 Subject: [PATCH 30/87] [api]: Improve fixed stars position and velocity --- .../kotlin/nebulosa/constants/Distance.kt | 2 +- .../src/main/kotlin/nebulosa/erfa/Erfa.kt | 146 ++++++++++++++++++ nebulosa-erfa/src/test/kotlin/ErfaTest.kt | 40 +++++ .../nebulosa/nova/astrometry/FixedStar.kt | 43 ++++-- .../src/test/kotlin/FixedStarTest.kt | 51 ++++-- 5 files changed, 253 insertions(+), 29 deletions(-) diff --git a/nebulosa-constants/src/main/kotlin/nebulosa/constants/Distance.kt b/nebulosa-constants/src/main/kotlin/nebulosa/constants/Distance.kt index 7a61b3c52..5470ef994 100644 --- a/nebulosa-constants/src/main/kotlin/nebulosa/constants/Distance.kt +++ b/nebulosa-constants/src/main/kotlin/nebulosa/constants/Distance.kt @@ -28,6 +28,6 @@ const val LIGHT_TIME_AU = AU_M / SPEED_OF_LIGHT const val SCHWARZSCHILD_RADIUS_OF_THE_SUN = 1.97412574336e-8 /** - * Speed of light (au per s). + * Speed of light (au per day). */ const val SPEED_OF_LIGHT_AU_DAY = SPEED_OF_LIGHT * DAYSEC / AU_M diff --git a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt index 4876436ba..4c5692312 100644 --- a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt +++ b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt @@ -3561,3 +3561,149 @@ fun eraAtic13(rightAscension: Angle, declination: Angle, tdb1: Double, tdb2: Dou return doubleArrayOf(ri, di, eo) } + +/** + * Convert position/velocity from spherical to Cartesian coordinates. + * + * theta longitude angle (radians) + * phi latitude angle (radians) + * r radial distance + * td rate of change of theta + * pd rate of change of phi + * rd rate of change of r + */ +fun eraS2pv( + theta: Angle, phi: Angle, r: Double, + td: Double, pd: Double, rd: Double +): PositionAndVelocity { + val st = sin(theta) + val ct = cos(theta) + val sp = sin(phi) + val cp = cos(phi) + val rcp = r * cp + val x = rcp * ct + val y = rcp * st + val rpd = r * pd + val w = rpd * sp - cp * rd + + return PositionAndVelocity(Vector3D(x, y, r * sp), Vector3D(-y * td - w * ct, x * td - w * st, rpd * cp + sp * rd)) +} + +/** + * Convert star catalog coordinates to position+velocity vector. + * + * @param rightAscension Right ascension (radians) + * @param declination Declination (radians) + * @param pmRA RA proper motion (radians/year) + * @param pmDEC Dec proper motion (radians/year) + * @param parallax Parallax (arcseconds) + * @param rv Radial velocity (km/s, positive = receding) + * + * @return pv-vector (au, au/day). + */ +fun eraStarpv( + rightAscension: Angle, declination: Angle, + pmRA: Angle, pmDEC: Angle, parallax: Double, rv: Double, +): PositionAndVelocity { + // Distance (au). + var w = max(parallax, 1e-7) + val r = (180.0 * 3600.0 / PI) / w + + val rd = DAYSEC * rv / AU_KM + + // Proper motion (radian/day). + val rad = pmRA / DAYSPERJY + val decd = pmDEC / DAYSPERJY + + // To pv-vector (au,au/day). + val pv = eraS2pv(rightAscension, declination, r, rad, decd, rd) + + return eraStarpv(pv) +} + +/** + * Convert star catalog coordinates to position+velocity vector. + * + * Modified to accept radians and au/day instead of arcseconds and km/s. + * + * @param rightAscension Right ascension (radians) + * @param declination Declination (radians) + * @param pmRA RA proper motion (radians/year) + * @param pmDEC Dec proper motion (radians/year) + * @param parallax Parallax (radians) + * @param rv Radial velocity (au/day, positive = receding) + * + * @return pv-vector (au, au/day). + */ +fun eraStarpvMod( + rightAscension: Angle, declination: Angle, + pmRA: Angle, pmDEC: Angle, parallax: Angle, rv: Velocity, +): PositionAndVelocity { + // Distance (au). + val r = 1 / max(parallax, 1e-13) + + // Proper motion (radian/day). + val rad = pmRA / DAYSPERJY + val decd = pmDEC / DAYSPERJY + + // To pv-vector (au,au/day). + val pv = eraS2pv(rightAscension, declination, r, rad, decd, rv) + + return eraStarpv(pv) +} + +private fun eraStarpv(pv: PositionAndVelocity): PositionAndVelocity { + // Isolate the radial component of the velocity (au/day). + val pu = pv.position.normalized + val vsr = pu.dot(pv.velocity) + val usr = pu * vsr + + // Isolate the transverse component of the velocity (au/day). + val ust = pv.velocity - usr + val vst = ust.length + + // Special-relativity dimensionless parameters. + val betsr = vsr / SPEED_OF_LIGHT_AU_DAY + val betst = vst / SPEED_OF_LIGHT_AU_DAY + + // Determine the observed-to-inertial correction terms. + var bett = betst + var betr = betsr + + var d = 0.0 + var del = 0.0 + var odd = 0.0 + var oddel = 0.0 + var od = 0.0 + var odel = 0.0 + + for (i in 0..99) { + d = 1.0 + betr + val w = betr * betr + bett * bett + del = -w / (sqrt(1.0 - w) + 1.0) + betr = d * betsr + del + bett = d * betst + + if (i > 0) { + val dd = abs(d - od) + val ddel = abs(del - odel) + if (i > 1 && dd >= odd && ddel >= oddel) break + odd = dd + oddel = ddel + } + + od = d + odel = del + } + + // Scale observed tangential velocity vector into inertial (au/d). + val ut = ust * d + + // Compute inertial radial velocity vector (au/d). + val ur = pu * (SPEED_OF_LIGHT_AU_DAY * (d * betsr + del)) + + // Combine the two to obtain the inertial space velocity vector. + val v = ur + ut + + return PositionAndVelocity(pv.position, v) +} diff --git a/nebulosa-erfa/src/test/kotlin/ErfaTest.kt b/nebulosa-erfa/src/test/kotlin/ErfaTest.kt index 12ab8d55d..8ab08d14a 100644 --- a/nebulosa-erfa/src/test/kotlin/ErfaTest.kt +++ b/nebulosa-erfa/src/test/kotlin/ErfaTest.kt @@ -1089,5 +1089,45 @@ class ErfaTest : StringSpec() { ri shouldBe (2.710126504531716819 plusOrMinus 1e-12) di shouldBe (0.1740632537627034482 plusOrMinus 1e-12) } + "eraAtic13" { + val (rc, dc, eo) = eraAtic13(2.710121572969038991, 0.1729371367218230438, 2456165.5, 0.401182685) + + rc shouldBe (2.710126504531716819 plusOrMinus 1e-12) + dc shouldBe (0.1740632537627034482 plusOrMinus 1e-12) + eo shouldBe (-0.002900618712657375647 plusOrMinus 1e-14) + } + "eraS2pv" { + val pv = eraS2pv(-3.21, 0.123, 0.456, -7.8e-6, 9.01e-6, -1.23e-5) + + pv.position[0] shouldBe (-0.4514964673880165228 plusOrMinus 1e-12) + pv.position[1] shouldBe (0.0309339427734258688 plusOrMinus 1e-12) + pv.position[2] shouldBe (0.0559466810510877933 plusOrMinus 1e-12) + + pv.velocity[0] shouldBe (0.1292270850663260170e-4 plusOrMinus 1e-16) + pv.velocity[1] shouldBe (0.2652814182060691422e-5 plusOrMinus 1e-16) + pv.velocity[2] shouldBe (0.2568431853930292259e-5 plusOrMinus 1e-16) + } + "eraStarpv" { + val pv = eraStarpv(0.01686756, -1.093989828, -1.78323516e-5, 2.336024047e-6, 0.74723, -21.6) + + pv.position[0] shouldBe (126668.5912743160601 plusOrMinus 1e-10) + pv.position[1] shouldBe (2136.792716839935195 plusOrMinus 1e-12) + pv.position[2] shouldBe (-245251.2339876830091 plusOrMinus 1e-10) + + pv.velocity[0] shouldBe (-0.4051854008955659551e-2 plusOrMinus 1e-13) + pv.velocity[1] shouldBe (-0.6253919754414777970e-2 plusOrMinus 1e-15) + pv.velocity[2] shouldBe (0.1189353714588109341e-1 plusOrMinus 1e-13) + } + "eraStarpvMod" { + val pv = eraStarpvMod(0.01686756, -1.093989828, -1.78323516e-5, 2.336024047e-6, 0.74723.arcsec, (-21.6).kms) + + pv.position[0] shouldBe (126668.5912743160601 plusOrMinus 1e-10) + pv.position[1] shouldBe (2136.792716839935195 plusOrMinus 1e-12) + pv.position[2] shouldBe (-245251.2339876830091 plusOrMinus 1e-10) + + pv.velocity[0] shouldBe (-0.4051854008955659551e-2 plusOrMinus 1e-13) + pv.velocity[1] shouldBe (-0.6253919754414777970e-2 plusOrMinus 1e-15) + pv.velocity[2] shouldBe (0.1189353714588109341e-1 plusOrMinus 1e-13) + } } } diff --git a/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/FixedStar.kt b/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/FixedStar.kt index de0287cec..b4bf2378f 100644 --- a/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/FixedStar.kt +++ b/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/FixedStar.kt @@ -2,10 +2,15 @@ package nebulosa.nova.astrometry import nebulosa.constants.* import nebulosa.erfa.PositionAndVelocity -import nebulosa.math.* +import nebulosa.erfa.eraStarpvMod +import nebulosa.math.Angle +import nebulosa.math.Vector3D +import nebulosa.math.Velocity +import nebulosa.math.toMetersPerSecond import nebulosa.nova.position.ICRF import nebulosa.time.InstantOfTime -import nebulosa.time.TimeJD +import nebulosa.time.UTC +import kotlin.math.cos import kotlin.math.max import kotlin.math.sin @@ -18,7 +23,7 @@ import kotlin.math.sin * * For objects whose proper motion across the sky has been detected, * you can supply velocities in milliarcseconds (mas) per year, and - * even a [parallax] and [radial] velocity if those are known. + * even a [parallax] and [radialVelocity] velocity if those are known. */ data class FixedStar( val ra: Angle, @@ -26,11 +31,13 @@ data class FixedStar( val pmRA: Angle = 0.0, val pmDEC: Angle = 0.0, val parallax: Angle = 0.0, - val radial: Velocity = 0.0, - val epoch: InstantOfTime = TimeJD.J2000, + val radialVelocity: Velocity = 0.0, + val epoch: InstantOfTime = UTC.J2000, ) : Body { - val positionAndVelocity by lazy { computePositionAndVelocity(ra, dec, pmRA, pmDEC, parallax, radial) } + // val positionAndVelocity by lazy { computePositionAndVelocity(ra, dec, pmRA, pmDEC, parallax, radialVelocity) } + // eraStarpvMod computes wrong values for Polaris ??? + val positionAndVelocity by lazy { eraStarpvMod(ra, dec, pmRA, pmDEC, parallax, radialVelocity) } override val center = 0 @@ -42,15 +49,15 @@ data class FixedStar( // Light-time returned is the projection of vector "pos_obs" onto the // unit vector "u1", divided by the speed of light. val lightTime = u1.dot(observer.position) / SPEED_OF_LIGHT_AU_DAY - val position = positionAndVelocity.position + positionAndVelocity.velocity * - (observer.time.tdb.whole - epoch.tt.whole + lightTime + observer.time.tdb.fraction - epoch.tt.fraction) - - observer.position + val position = (positionAndVelocity.position + positionAndVelocity.velocity * + (observer.time.tdb.whole - epoch.tdb.whole + lightTime + observer.time.tdb.fraction - epoch.tdb.fraction) - + observer.position) return PositionAndVelocity(position, observer.velocity - positionAndVelocity.velocity) } override fun compute(time: InstantOfTime): PositionAndVelocity { val position = - positionAndVelocity.position + positionAndVelocity.velocity * (time.tdb.whole - epoch.tt.whole + time.tdb.fraction - epoch.tt.fraction) + positionAndVelocity.position + positionAndVelocity.velocity * (time.tdb.whole - epoch.tdb.whole + time.tdb.fraction - epoch.tdb.fraction) return PositionAndVelocity(position, positionAndVelocity.velocity) } @@ -62,15 +69,15 @@ data class FixedStar( private fun computePositionAndVelocity( ra: Angle, dec: Angle, pmRA: Angle = 0.0, pmDEC: Angle = 0.0, - parallax: Angle = 0.0, radial: Velocity = 0.0, + parallax: Angle = 0.0, radialVelocity: Velocity = 0.0, ): PositionAndVelocity { val plx = max(MIN_PARALLAX, parallax) // Computing the star's position as an ICRF position and velocity. val dist = 1.0 / sin(plx) - val cra = ra.cos - val sra = ra.sin - val cdc = dec.cos - val sdc = dec.sin + val cra = cos(ra) + val sra = sin(ra) + val cdc = cos(dec) + val sdc = sin(dec) val position = Vector3D( dist * cdc * cra, @@ -78,15 +85,17 @@ data class FixedStar( dist * sdc, ) + val rvInMetersPerSecond = radialVelocity.toMetersPerSecond + // Compute Doppler factor, which accounts for change in light travel time to star. - val k = 1.0 / (1.0 - radial.toMetersPerSecond / SPEED_OF_LIGHT) + val k = 1.0 / (1.0 - rvInMetersPerSecond / SPEED_OF_LIGHT) // Convert proper motion and radial velocity to orthogonal // components of motion with units of au/day. val pmr = pmRA / (plx * DAYSPERJY) * k val pmd = pmDEC / (plx * DAYSPERJY) * k - val rvl = radial.toKilometersPerSecond * DAYSEC / AU_KM * k + val rvl = rvInMetersPerSecond * DAYSEC / AU_M * k val velocity = Vector3D( -pmr * sra - pmd * sdc * cra + rvl * cdc * cra, diff --git a/nebulosa-nova/src/test/kotlin/FixedStarTest.kt b/nebulosa-nova/src/test/kotlin/FixedStarTest.kt index 1cecd0eb3..73331aa14 100644 --- a/nebulosa-nova/src/test/kotlin/FixedStarTest.kt +++ b/nebulosa-nova/src/test/kotlin/FixedStarTest.kt @@ -1,11 +1,10 @@ import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.doubles.plusOrMinus +import io.kotest.matchers.doubles.shouldBeExactly import io.kotest.matchers.shouldBe import nebulosa.math.* -import nebulosa.nasa.daf.RemoteDaf -import nebulosa.nasa.spk.Spk import nebulosa.nova.astrometry.FixedStar -import nebulosa.nova.astrometry.SpiceKernel +import nebulosa.nova.astrometry.VSOP87E import nebulosa.nova.position.Barycentric import nebulosa.time.IERS import nebulosa.time.IERSA @@ -13,6 +12,7 @@ import nebulosa.time.TimeYMDHMS import nebulosa.time.UTC import java.nio.file.Path import kotlin.io.path.inputStream +import kotlin.math.truncate class FixedStarTest : StringSpec() { @@ -28,17 +28,46 @@ class FixedStarTest : StringSpec() { (44.48).mas, (-11.85).mas, (7.54).mas, (-16.42).kms, ) - val spk = Spk(RemoteDaf("https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/a_old_versions/de421.bsp")) - val kernel = SpiceKernel(spk) + val astrometric = VSOP87E.EARTH.at(UTC(TimeYMDHMS(2024, 2, 17, 12, 0, 0.0))) + .observe(star) - val astrometric = kernel[399] - .at(UTC(TimeYMDHMS(2023, 1, 1, 15, 0, 0.0))).observe(star) + val (ra, dec) = astrometric.equatorialAtDate() - val (ra, dec, dist) = astrometric.equatorialAtDate() + with(ra.normalized.hms()) { + truncate(this[0]) shouldBeExactly 3.0 + truncate(this[1]) shouldBeExactly 2.0 + this[2] shouldBe (3.9 plusOrMinus 15.0) + } - ra.normalized.toHours shouldBe (3.0115471963487153 plusOrMinus 1e-8) - dec.toDegrees shouldBe (89.36032606627879 plusOrMinus 1e-8) - dist shouldBe (27355995.0433298 plusOrMinus 1e-6) + with(dec.dms()) { + truncate(this[0]) shouldBeExactly 89.0 + truncate(this[1]) shouldBe (22.0 plusOrMinus 1.0) + this[2] shouldBe (15.8 plusOrMinus 50.0) + } + } + "barnard's star" { + // https://api.noctuasky.com/api/v1/skysources/name/NAME%20Barnard's%20Star + val star = FixedStar( + 269.452082497514.deg, 4.6933642650633.deg, + (-802.803).mas, (10362.542).mas, (547.451).mas, (-110.51).kms, + ) + + val astrometric = VSOP87E.EARTH.at(UTC(TimeYMDHMS(2024, 2, 17, 12, 0, 0.0))) + .observe(star) + + val (ra, dec) = astrometric.equatorialAtDate() + + with(ra.normalized.hms()) { + truncate(this[0]) shouldBeExactly 17.0 + truncate(this[1]) shouldBeExactly 58.0 + this[2] shouldBe (57.8 plusOrMinus 1.0) + } + + with(dec.dms()) { + truncate(this[0]) shouldBeExactly 4.0 + truncate(this[1]) shouldBeExactly 45.0 + this[2] shouldBe (25.5 plusOrMinus 10.0) + } } } } From 3b0f1c884ba36467265203f2a45ad21108106be6 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 17 Feb 2024 15:53:36 -0300 Subject: [PATCH 31/87] [api]: Improve annotation taking account the proper motion, parallax and radial velocity of stars --- .../kotlin/nebulosa/api/image/ImageService.kt | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt index e5f95e847..c972b38e3 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt @@ -17,10 +17,14 @@ import nebulosa.indi.device.camera.Camera import nebulosa.io.transferAndClose import nebulosa.log.loggerFor import nebulosa.math.* +import nebulosa.nova.astrometry.VSOP87E +import nebulosa.nova.position.Barycentric import nebulosa.sbd.SmallBodyDatabaseService import nebulosa.skycatalog.ClassificationType import nebulosa.star.detection.ImageStar import nebulosa.star.detection.StarDetector +import nebulosa.time.TimeYMDHMS +import nebulosa.time.UTC import nebulosa.wcs.WCS import nebulosa.wcs.WCSException import org.springframework.http.HttpStatus @@ -28,6 +32,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor import org.springframework.stereotype.Service import org.springframework.web.server.ResponseStatusException import java.nio.file.Path +import java.time.LocalDateTime import java.util.* import java.util.concurrent.CompletableFuture import javax.imageio.ImageIO @@ -63,8 +68,8 @@ class ImageService( var stretchParams = ScreenTransformFunction.Parameters(midtone, shadow, highlight) val shouldBeTransformed = autoStretch || manualStretch - || mirrorHorizontal || mirrorVertical || invert - || scnrEnabled + || mirrorHorizontal || mirrorVertical || invert + || scnrEnabled var transformedImage = if (shouldBeTransformed) image.clone() else image val instrument = camera?.name ?: image.header.instrument @@ -143,9 +148,9 @@ class ImageService( val annotations = Vector() val tasks = ArrayList>() - val dateTime = image.header.observationDate + val dateTime = image.header.observationDate ?: LocalDateTime.now() - if (minorPlanets && dateTime != null) { + if (minorPlanets) { threadPoolTaskExecutor.submitCompletable { val latitude = image.header.latitude ?: 0.0 val longitude = image.header.longitude ?: 0.0 @@ -183,9 +188,9 @@ class ImageService( .also(tasks::add) } - // val barycentric = VSOP87E.EARTH.at(UTC(TimeYMDHMS(dateTime))) - if (starsAndDSOs) { + val barycentric = VSOP87E.EARTH.at(UTC(TimeYMDHMS(dateTime))) + threadPoolTaskExecutor.submitCompletable { LOG.info("finding star/DSO annotations. dateTime={}, calibration={}", dateTime, calibration) @@ -194,7 +199,8 @@ class ImageService( var count = 0 for (entry in catalog) { - val (x, y) = wcs.skyToPix(entry.rightAscensionJ2000, entry.declinationJ2000) + val astrometric = barycentric.observe(entry).equatorial() + val (x, y) = wcs.skyToPix(astrometric.longitude.normalized, astrometric.latitude) val annotation = if (entry.type.classification == ClassificationType.STAR) ImageAnnotation(x, y, star = entry) else ImageAnnotation(x, y, dso = entry) annotations.add(annotation) From d378597d60a6cb966b330b350c63d18bad4c771d Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sun, 18 Feb 2024 00:01:51 -0300 Subject: [PATCH 32/87] [desktop]: Sort devices in dropdown menu --- .../app/alignment/alignment.component.html | 8 +-- .../src/app/alignment/alignment.component.ts | 19 ++++--- .../app/flat-wizard/flat-wizard.component.ts | 49 ++++++++++++++++++- desktop/src/app/home/home.component.ts | 4 +- desktop/src/app/image/image.component.ts | 2 +- desktop/src/app/indi/indi.component.ts | 6 +-- .../src/app/sequencer/sequencer.component.ts | 11 +++-- .../device-list-menu.component.ts | 4 +- desktop/src/shared/utils/comparators.ts | 9 ++-- 9 files changed, 81 insertions(+), 31 deletions(-) diff --git a/desktop/src/app/alignment/alignment.component.html b/desktop/src/app/alignment/alignment.component.html index 0364e7083..5261145ed 100644 --- a/desktop/src/app/alignment/alignment.component.html +++ b/desktop/src/app/alignment/alignment.component.html @@ -14,9 +14,9 @@ (ngModelChange)="mountChanged()" styleClass="p-inputtext-sm border-0 max-w-full" emptyMessage="No mount found" /> - -
@@ -26,9 +26,9 @@ emptyMessage="No guide output found" /> - -
diff --git a/desktop/src/app/alignment/alignment.component.ts b/desktop/src/app/alignment/alignment.component.ts index 36d8034a3..d8c103188 100644 --- a/desktop/src/app/alignment/alignment.component.ts +++ b/desktop/src/app/alignment/alignment.component.ts @@ -9,6 +9,7 @@ import { Camera, EMPTY_CAMERA, EMPTY_CAMERA_START_CAPTURE, ExposureTimeUnit } fr import { EMPTY_GUIDE_OUTPUT, GuideDirection, GuideOutput } from '../../shared/types/guider.types' import { EMPTY_MOUNT, Mount } from '../../shared/types/mount.types' import { DEFAULT_SOLVER_TYPES, EMPTY_PLATE_SOLVER_OPTIONS } from '../../shared/types/settings.types' +import { deviceComparator } from '../../shared/utils/comparators' import { AppComponent } from '../app.component' import { CameraComponent } from '../camera/camera.component' @@ -91,6 +92,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { electron.on('CAMERA.ATTACHED', event => { ngZone.run(() => { this.cameras.push(event.device) + this.cameras.sort(deviceComparator) }) }) @@ -100,10 +102,11 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { if (index >= 0) { if (this.cameras[index] === this.camera) { - Object.assign(this.camera, EMPTY_CAMERA) + Object.assign(this.camera, this.cameras[0] ?? EMPTY_CAMERA) } this.cameras.splice(index, 1) + this.cameras.sort(deviceComparator) } }) }) @@ -119,6 +122,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { electron.on('MOUNT.ATTACHED', event => { ngZone.run(() => { this.mounts.push(event.device) + this.mounts.sort(deviceComparator) }) }) @@ -128,10 +132,11 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { if (index >= 0) { if (this.mounts[index] === this.mount) { - Object.assign(this.mount, EMPTY_MOUNT) + Object.assign(this.mount, this.mounts[0] ?? EMPTY_MOUNT) } this.mounts.splice(index, 1) + this.mounts.sort(deviceComparator) } }) }) @@ -147,6 +152,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { electron.on('GUIDE_OUTPUT.ATTACHED', event => { ngZone.run(() => { this.guideOutputs.push(event.device) + this.guideOutputs.sort(deviceComparator) }) }) @@ -156,10 +162,11 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { if (index >= 0) { if (this.guideOutputs[index] === this.guideOutput) { - Object.assign(this.guideOutput, EMPTY_GUIDE_OUTPUT) + Object.assign(this.guideOutput, this.guideOutputs[0] ?? EMPTY_GUIDE_OUTPUT) } this.guideOutputs.splice(index, 1) + this.guideOutputs.sort(deviceComparator) } }) }) @@ -215,9 +222,9 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } async ngAfterViewInit() { - this.cameras = await this.api.cameras() - this.mounts = await this.api.mounts() - this.guideOutputs = await this.api.guideOutputs() + this.cameras = (await this.api.cameras()).sort(deviceComparator) + this.mounts = (await this.api.mounts()).sort(deviceComparator) + this.guideOutputs = (await this.api.guideOutputs()).sort(deviceComparator) this.loadPreference() } diff --git a/desktop/src/app/flat-wizard/flat-wizard.component.ts b/desktop/src/app/flat-wizard/flat-wizard.component.ts index 2aef1d0fd..4ba1e8ee9 100644 --- a/desktop/src/app/flat-wizard/flat-wizard.component.ts +++ b/desktop/src/app/flat-wizard/flat-wizard.component.ts @@ -8,6 +8,7 @@ import { PrimeService } from '../../shared/services/prime.service' import { Camera, EMPTY_CAMERA, EMPTY_CAMERA_START_CAPTURE, updateCameraStartCaptureFromCamera } from '../../shared/types/camera.types' import { FlatWizardRequest } from '../../shared/types/flat-wizard.types' import { EMPTY_WHEEL, FilterSlot, FilterWheel } from '../../shared/types/wheel.types' +import { deviceComparator } from '../../shared/utils/comparators' import { AppComponent } from '../app.component' import { CameraComponent } from '../camera/camera.component' @@ -94,6 +95,28 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { } }) + electron.on('CAMERA.ATTACHED', event => { + ngZone.run(() => { + this.cameras.push(event.device) + this.cameras.sort(deviceComparator) + }) + }) + + electron.on('CAMERA.DETACHED', event => { + ngZone.run(() => { + const index = this.cameras.findIndex(e => e.name === event.device.name) + + if (index >= 0) { + if (this.cameras[index] === this.camera) { + Object.assign(this.camera, this.cameras[0] ?? EMPTY_CAMERA) + } + + this.cameras.splice(index, 1) + this.cameras.sort(deviceComparator) + } + }) + }) + electron.on('WHEEL.UPDATED', event => { if (event.device.name === this.wheel.name) { ngZone.run(() => { @@ -102,11 +125,33 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { }) } }) + + electron.on('WHEEL.ATTACHED', event => { + ngZone.run(() => { + this.wheels.push(event.device) + this.wheels.sort(deviceComparator) + }) + }) + + electron.on('WHEEL.DETACHED', event => { + ngZone.run(() => { + const index = this.wheels.findIndex(e => e.name === event.device.name) + + if (index >= 0) { + if (this.wheels[index] === this.wheel) { + Object.assign(this.wheel, this.wheels[0] ?? EMPTY_WHEEL) + } + + this.wheels.splice(index, 1) + this.wheels.sort(deviceComparator) + } + }) + }) } async ngAfterViewInit() { - this.cameras = await this.api.cameras() - this.wheels = await this.api.wheels() + this.cameras = (await this.api.cameras()).sort(deviceComparator) + this.wheels = (await this.api.wheels()).sort(deviceComparator) } @HostListener('window:unload') diff --git a/desktop/src/app/home/home.component.ts b/desktop/src/app/home/home.component.ts index 6ecda8d4d..c8e76a91b 100644 --- a/desktop/src/app/home/home.component.ts +++ b/desktop/src/app/home/home.component.ts @@ -14,7 +14,7 @@ import { Focuser } from '../../shared/types/focuser.types' import { ConnectionDetails, HomeWindowType } from '../../shared/types/home.types' import { Mount } from '../../shared/types/mount.types' import { FilterWheel } from '../../shared/types/wheel.types' -import { compareDevice } from '../../shared/utils/comparators' +import { deviceComparator } from '../../shared/utils/comparators' import { AppComponent } from '../app.component' type MappedDevice = { @@ -280,7 +280,7 @@ export class HomeComponent implements AfterContentInit, OnDestroy { if (devices.length === 0) return if (devices.length === 1) return this.openDeviceWindow(type, devices[0] as any) - for (const device of [...devices].sort(compareDevice)) { + for (const device of [...devices].sort(deviceComparator)) { this.deviceModel.push({ icon: 'mdi mdi-connection', label: device.name, diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 4234076e6..37e660625 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -19,7 +19,7 @@ import { Angle, AstronomicalObject, DeepSkyObject, EquatorialCoordinateJ2000, St import { Camera } from '../../shared/types/camera.types' import { DetectedStar, EMPTY_IMAGE_SOLVED, FITSHeaderItem, ImageAnnotation, ImageChannel, ImageData, ImageInfo, ImagePreference, ImageStatisticsBitOption, SCNRProtectionMethod, SCNR_PROTECTION_METHODS } from '../../shared/types/image.types' import { Mount } from '../../shared/types/mount.types' -import { DEFAULT_SOLVER_TYPES, PlateSolverType } from '../../shared/types/settings.types' +import { DEFAULT_SOLVER_TYPES } from '../../shared/types/settings.types' import { CoordinateInterpolator, InterpolatedCoordinate } from '../../shared/utils/coordinate-interpolation' import { AppComponent } from '../app.component' diff --git a/desktop/src/app/indi/indi.component.ts b/desktop/src/app/indi/indi.component.ts index 0087b872b..0c35a5a44 100644 --- a/desktop/src/app/indi/indi.component.ts +++ b/desktop/src/app/indi/indi.component.ts @@ -4,7 +4,7 @@ import { MenuItem } from 'primeng/api' import { ApiService } from '../../shared/services/api.service' import { ElectronService } from '../../shared/services/electron.service' import { Device, INDIProperty, INDIPropertyItem, INDISendProperty } from '../../shared/types/device.types' -import { compareDevice, compareText } from '../../shared/utils/comparators' +import { deviceComparator, textComparator } from '../../shared/utils/comparators' import { AppComponent } from '../app.component' @Component({ @@ -73,7 +73,7 @@ export class INDIComponent implements AfterViewInit, OnDestroy { ...await this.api.mounts(), ...await this.api.focusers(), ...await this.api.wheels(), - ].sort(compareDevice) + ].sort(deviceComparator) this.device = this.devices[0] } @@ -130,7 +130,7 @@ export class INDIComponent implements AfterViewInit, OnDestroy { if (this.groups.length === 0 || groupsChanged) { this.groups = Array.from(groups) - .sort(compareText) + .sort(textComparator) .map(e => { icon: 'mdi mdi-sitemap', label: e, diff --git a/desktop/src/app/sequencer/sequencer.component.ts b/desktop/src/app/sequencer/sequencer.component.ts index daa08a1cd..8c97c9729 100644 --- a/desktop/src/app/sequencer/sequencer.component.ts +++ b/desktop/src/app/sequencer/sequencer.component.ts @@ -11,6 +11,7 @@ import { Camera, CameraCaptureElapsed, CameraStartCapture } from '../../shared/t import { Focuser } from '../../shared/types/focuser.types' import { EMPTY_SEQUENCE_PLAN, SequenceCaptureMode, SequencePlan, SequencerElapsed } from '../../shared/types/sequencer.types' import { FilterWheel } from '../../shared/types/wheel.types' +import { deviceComparator } from '../../shared/utils/comparators' import { AppComponent } from '../app.component' import { CameraComponent } from '../camera/camera.component' import { FilterWheelComponent } from '../filterwheel/filterwheel.component' @@ -173,9 +174,9 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { } async ngAfterContentInit() { - this.cameras = await this.api.cameras() - this.wheels = await this.api.wheels() - this.focusers = await this.api.focusers() + this.cameras = (await this.api.cameras()).sort(deviceComparator) + this.wheels = (await this.api.wheels()).sort(deviceComparator) + this.focusers = (await this.api.focusers()).sort(deviceComparator) this.loadSavedJsonFileFromPathOrAddDefault() @@ -191,8 +192,8 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { add() { const camera = this.camera ?? this.cameras[0] - const wheel = this.wheel ?? this.wheels[0] - const focuser = this.focuser ?? this.focusers[0] + // const wheel = this.wheel ?? this.wheels[0] + // const focuser = this.focuser ?? this.focusers[0] this.plan.entries.push({ enabled: true, diff --git a/desktop/src/shared/components/device-list-menu/device-list-menu.component.ts b/desktop/src/shared/components/device-list-menu/device-list-menu.component.ts index a88fc98ba..c9a973ec0 100644 --- a/desktop/src/shared/components/device-list-menu/device-list-menu.component.ts +++ b/desktop/src/shared/components/device-list-menu/device-list-menu.component.ts @@ -2,8 +2,8 @@ import { Component, Input, ViewChild } from '@angular/core' import { MenuItem, MessageService } from 'primeng/api' import { SEPARATOR_MENU_ITEM } from '../../constants' import { Device } from '../../types/device.types' +import { deviceComparator } from '../../utils/comparators' import { DialogMenuComponent } from '../dialog-menu/dialog-menu.component' -import { compareDevice } from '../../utils/comparators' @Component({ selector: 'neb-device-list-menu', @@ -48,7 +48,7 @@ export class DeviceListMenuComponent { model.push(SEPARATOR_MENU_ITEM) } - for (const device of devices.sort(compareDevice)) { + for (const device of devices.sort(deviceComparator)) { model.push({ icon: 'mdi mdi-connection', label: device.name, diff --git a/desktop/src/shared/utils/comparators.ts b/desktop/src/shared/utils/comparators.ts index ffef54d28..7c29fc757 100644 --- a/desktop/src/shared/utils/comparators.ts +++ b/desktop/src/shared/utils/comparators.ts @@ -1,9 +1,6 @@ import { Device } from '../types/device.types' -export function compareText(a: string, b: string) { - return a.localeCompare(b) -} +export type Comparator = (a: T, b: T) => number -export function compareDevice(a: Device, b: Device) { - return compareText(a.name, b.name) -} \ No newline at end of file +export const textComparator: Comparator = (a: string, b: string) => a.localeCompare(b) +export const deviceComparator: Comparator = (a: Device, b: Device) => textComparator(a.name, b.name) From 8a6ec6b0241c9902ede06d990a38dc017437275e Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sun, 18 Feb 2024 00:08:54 -0300 Subject: [PATCH 33/87] [api]: Prevent start TPPA with exposure less than 1 second --- .../kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt index b98da8fcf..821a281c9 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt @@ -27,6 +27,7 @@ data class TPPAJob( @JvmField val cameraRequest = request.capture.copy( savePath = Files.createTempDirectory("tppa"), exposureAmount = 1, exposureDelay = Duration.ZERO, + exposureTime = maxOf(request.capture.exposureTime, MIN_EXPOSURE_TIME), frameType = FrameType.LIGHT, autoSave = false, autoSubFolderMode = AutoSubFolderMode.OFF ) @@ -85,4 +86,9 @@ data class TPPAJob( override fun contains(data: Any): Boolean { return data === camera || data === mount || super.contains(data) } + + companion object { + + @JvmStatic private val MIN_EXPOSURE_TIME: Duration = Duration.ofSeconds(1L) + } } From 02818ba829bdd57e3866aac6b7af7e13f6d357b4 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sun, 18 Feb 2024 00:18:23 -0300 Subject: [PATCH 34/87] [desktop]: Set false to "autoDisplayFirst" property on all dropdowns --- desktop/src/app/alignment/alignment.component.html | 6 +++--- desktop/src/app/atlas/atlas.component.html | 4 ++-- desktop/src/app/flat-wizard/flat-wizard.component.html | 4 ++-- desktop/src/app/framing/framing.component.html | 2 +- desktop/src/app/guider/guider.component.html | 2 +- desktop/src/app/image/image.component.html | 7 ++++--- desktop/src/app/indi/indi.component.html | 2 +- desktop/src/app/settings/settings.component.html | 5 +++-- 8 files changed, 17 insertions(+), 15 deletions(-) diff --git a/desktop/src/app/alignment/alignment.component.html b/desktop/src/app/alignment/alignment.component.html index 5261145ed..08c56d023 100644 --- a/desktop/src/app/alignment/alignment.component.html +++ b/desktop/src/app/alignment/alignment.component.html @@ -2,7 +2,7 @@
- @@ -10,7 +10,7 @@
- @@ -23,7 +23,7 @@ + emptyMessage="No guide output found" [autoDisplayFirst]="false" /> + styleClass="border-0" [autoDisplayFirst]="false">
{{ item | enum }} @@ -326,7 +326,7 @@
+ styleClass="border-0" [autoDisplayFirst]="false">
{{ item | enum }} diff --git a/desktop/src/app/flat-wizard/flat-wizard.component.html b/desktop/src/app/flat-wizard/flat-wizard.component.html index e8fd42240..0b82d55d5 100644 --- a/desktop/src/app/flat-wizard/flat-wizard.component.html +++ b/desktop/src/app/flat-wizard/flat-wizard.component.html @@ -2,7 +2,7 @@
- @@ -10,7 +10,7 @@
- diff --git a/desktop/src/app/framing/framing.component.html b/desktop/src/app/framing/framing.component.html index 69f584a7b..207a67842 100644 --- a/desktop/src/app/framing/framing.component.html +++ b/desktop/src/app/framing/framing.component.html @@ -42,7 +42,7 @@
- +
{{ item.regime }} {{ item.id }} diff --git a/desktop/src/app/guider/guider.component.html b/desktop/src/app/guider/guider.component.html index b7fa15aba..34a0758e6 100644 --- a/desktop/src/app/guider/guider.component.html +++ b/desktop/src/app/guider/guider.component.html @@ -141,7 +141,7 @@
+ [autoDisplayFirst]="false" styleClass="p-inputtext-sm border-0" emptyMessage="No guide output found" /> diff --git a/desktop/src/app/image/image.component.html b/desktop/src/app/image/image.component.html index a442ce890..347ed8af0 100644 --- a/desktop/src/app/image/image.component.html +++ b/desktop/src/app/image/image.component.html @@ -142,7 +142,7 @@
- + @@ -276,7 +276,7 @@
+ [options]="scnrProtectionMethodOptions" styleClass="border-0" [autoDisplayFirst]="false">
{{ item | enum }} @@ -386,7 +386,8 @@
+ [autoDisplayFirst]="false" styleClass="p-inputtext-sm border-0" appendTo="body" + (ngModelChange)="statisticsBitLengthChanged()" />
diff --git a/desktop/src/app/indi/indi.component.html b/desktop/src/app/indi/indi.component.html index 8bb0f62eb..69fc7616b 100644 --- a/desktop/src/app/indi/indi.component.html +++ b/desktop/src/app/indi/indi.component.html @@ -3,7 +3,7 @@
+ styleClass="border-0" emptyMessage="No device found" [autoDisplayFirst]="false" />
diff --git a/desktop/src/app/settings/settings.component.html b/desktop/src/app/settings/settings.component.html index 3e921ed58..507732abe 100644 --- a/desktop/src/app/settings/settings.component.html +++ b/desktop/src/app/settings/settings.component.html @@ -15,7 +15,8 @@
+ optionLabel="name" dataKey="id" styleClass="p-inputtext-sm border-0" emptyMessage="No location found" + [autoDisplayFirst]="false" />
@@ -30,7 +31,7 @@
+ (ngModelChange)="plateSolvers.get(plateSolverType)!.type = $event; save()" [autoDisplayFirst]="false" />
From 68d9a2a62dd15449f7010c5a61e9a47a0cc0778a Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sun, 18 Feb 2024 12:30:36 -0300 Subject: [PATCH 35/87] [desktop]: Allow window.resize on development mode --- desktop/app/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/app/main.ts b/desktop/app/main.ts index d8eb70bc2..2bd2904fb 100644 --- a/desktop/app/main.ts +++ b/desktop/app/main.ts @@ -416,7 +416,7 @@ try { ipcMain.handle('WINDOW.RESIZE', (event, data: number) => { const window = findWindowById(event.sender.id)?.window - if (!window || window.isResizable()) return false + if (!window || (!serve && window.isResizable())) return false const size = window.getSize() window.setSize(size[0], Math.max(size[1], data)) From f8f3192fd070b1363b7c4178caa82fbf1fdaf434 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sun, 18 Feb 2024 13:53:57 -0300 Subject: [PATCH 36/87] [api][desktop]: Prepare to multiple connections; Add device id property and use it instead of name; New connection UI --- .../nebulosa/api/cameras/CameraSerializer.kt | 2 + .../api/connection/ConnectionController.kt | 16 +- .../api/connection/ConnectionService.kt | 115 ++++++++++--- .../api/focusers/FocuserSerializer.kt | 2 + .../api/guiding/GuideOutputSerializer.kt | 2 + .../nebulosa/api/mounts/MountSerializer.kt | 2 + .../nebulosa/api/wheels/WheelSerializer.kt | 2 + .../src/app/alignment/alignment.component.ts | 12 +- desktop/src/app/camera/camera.component.ts | 6 +- .../app/filterwheel/filterwheel.component.ts | 4 +- .../app/flat-wizard/flat-wizard.component.ts | 10 +- desktop/src/app/focuser/focuser.component.ts | 4 +- desktop/src/app/guider/guider.component.ts | 4 +- desktop/src/app/home/home.component.html | 76 ++++++--- desktop/src/app/home/home.component.ts | 156 +++++++++--------- desktop/src/app/image/image.component.ts | 2 +- desktop/src/app/indi/indi.component.ts | 2 +- desktop/src/app/mount/mount.component.ts | 4 +- .../src/app/sequencer/sequencer.component.ts | 6 +- desktop/src/shared/services/api.service.ts | 12 +- .../src/shared/services/preference.service.ts | 4 +- desktop/src/shared/types/camera.types.ts | 2 + desktop/src/shared/types/device.types.ts | 2 + desktop/src/shared/types/focuser.types.ts | 2 + desktop/src/shared/types/guider.types.ts | 2 + desktop/src/shared/types/home.types.ts | 7 +- desktop/src/shared/types/mount.types.ts | 2 + desktop/src/shared/types/wheel.types.ts | 2 + .../alpaca/indi/client/AlpacaClient.kt | 11 +- .../alpaca/indi/devices/ASCOMDevice.kt | 10 +- .../indi/devices/cameras/ASCOMCamera.kt | 55 +++--- .../kotlin/nebulosa/indi/client/INDIClient.kt | 57 ++++++- .../client/connection/INDISocketConnection.kt | 4 +- .../indi/client/device/FilterWheelDevice.kt | 17 +- .../indi/client/device/FocuserDevice.kt | 49 +++--- .../nebulosa/indi/client/device/GPSDevice.kt | 13 +- .../nebulosa/indi/client/device/INDIDevice.kt | 51 +++--- ...andler.kt => INDIDeviceProtocolHandler.kt} | 46 ++---- .../indi/client/device/camera/AsiCamera.kt | 6 +- .../indi/client/device/camera/CameraDevice.kt | 70 ++++---- .../indi/client/device/camera/SVBonyCamera.kt | 6 +- .../indi/client/device/camera/SimCamera.kt | 6 +- .../client/device/mount/IoptronV3Mount.kt | 8 +- .../indi/client/device/mount/MountDevice.kt | 52 +++--- .../kotlin/nebulosa/indi/device/Device.kt | 8 +- .../indi/device/INDIDeviceProvider.kt | 2 +- .../nebulosa/indi/device/MessageSender.kt | 2 + 47 files changed, 558 insertions(+), 377 deletions(-) rename nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/{DeviceProtocolHandler.kt => INDIDeviceProtocolHandler.kt} (88%) diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraSerializer.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraSerializer.kt index 162ca36df..0a01653b8 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraSerializer.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraSerializer.kt @@ -12,6 +12,8 @@ class CameraSerializer(private val capturesPath: Path) : StdSerializer(C override fun serialize(value: Camera, gen: JsonGenerator, provider: SerializerProvider) { gen.writeStartObject() + gen.writeStringField("sender", value.sender.id) + gen.writeStringField("id", value.id) gen.writeStringField("name", value.name) gen.writeBooleanField("connected", value.connected) gen.writeBooleanField("exposuring", value.exposuring) diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionController.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionController.kt index f0c5f4d47..91610ed97 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionController.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionController.kt @@ -18,17 +18,15 @@ class ConnectionController( @RequestParam @Valid @NotBlank host: String, @RequestParam @Valid @Range(min = 1, max = 65535) port: Int, @RequestParam(required = false, defaultValue = "INDI") type: ConnectionType, - ) { - connectionService.connect(host, port, type) - } + ) = connectionService.connect(host, port, type) - @DeleteMapping - fun disconnect() { - connectionService.disconnect() + @DeleteMapping("{id}") + fun disconnect(@PathVariable id: String) { + connectionService.disconnect(id) } - @GetMapping - fun connectionStatus(): Boolean { - return connectionService.connectionStatus() + @GetMapping("{id}") + fun connectionStatus(@PathVariable id: String): Boolean { + return connectionService.connectionStatus(id) } } diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt index 670dae012..b76d73498 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt @@ -26,18 +26,16 @@ class ConnectionService( private val alpacaHttpClient: OkHttpClient, ) : Closeable { - @Volatile private var provider: INDIDeviceProvider? = null + private val providers = LinkedHashMap() - fun connectionStatus(): Boolean { - return provider != null + fun connectionStatus(id: String): Boolean { + return id in providers } @Synchronized - fun connect(host: String, port: Int, type: ConnectionType) { + fun connect(host: String, port: Int, type: ConnectionType): String { try { - disconnect() - - provider = when (type) { + val provider = when (type) { ConnectionType.INDI -> { val client = INDIClient(host, port) client.registerDeviceEventHandler(eventBus::post) @@ -53,6 +51,10 @@ class ConnectionService( client } } + + providers[provider.id] = provider + + return provider.id } catch (e: Throwable) { LOG.error(e) @@ -61,69 +63,130 @@ class ConnectionService( } @Synchronized - fun disconnect() { - provider?.close() - provider = null + fun disconnect(id: String) { + providers[id]?.close() + providers.remove(id) + } + + fun disconnectAll() { + providers.forEach { it.value.close() } + providers.clear() } override fun close() { - disconnect() + disconnectAll() + } + + fun cameras(id: String): List { + return providers[id]?.cameras() ?: emptyList() + } + + fun mounts(id: String): List { + return providers[id]?.mounts() ?: emptyList() + } + + fun focusers(id: String): List { + return providers[id]?.focusers() ?: emptyList() + } + + fun wheels(id: String): List { + return providers[id]?.wheels() ?: emptyList() + } + + fun gpss(id: String): List { + return providers[id]?.gps() ?: emptyList() + } + + fun guideOutputs(id: String): List { + return providers[id]?.guideOutputs() ?: emptyList() + } + + fun thermometers(id: String): List { + return providers[id]?.thermometers() ?: emptyList() } fun cameras(): List { - return provider?.cameras() ?: emptyList() + return providers.values.flatMap { it.cameras() } } fun mounts(): List { - return provider?.mounts() ?: emptyList() + return providers.values.flatMap { it.mounts() } } fun focusers(): List { - return provider?.focusers() ?: emptyList() + return providers.values.flatMap { it.focusers() } } fun wheels(): List { - return provider?.wheels() ?: emptyList() + return providers.values.flatMap { it.wheels() } } - fun gps(): List { - return provider?.gps() ?: emptyList() + fun gpss(): List { + return providers.values.flatMap { it.gps() } } fun guideOutputs(): List { - return provider?.guideOutputs() ?: emptyList() + return providers.values.flatMap { it.guideOutputs() } } fun thermometers(): List { - return provider?.thermometers() ?: emptyList() + return providers.values.flatMap { it.thermometers() } + } + + fun camera(id: String, name: String): Camera? { + return providers[id]?.camera(name) + } + + fun mount(id: String, name: String): Mount? { + return providers[id]?.mount(name) + } + + fun focuser(id: String, name: String): Focuser? { + return providers[id]?.focuser(name) + } + + fun wheel(id: String, name: String): FilterWheel? { + return providers[id]?.wheel(name) + } + + fun gps(id: String, name: String): GPS? { + return providers[id]?.gps(name) + } + + fun guideOutput(id: String, name: String): GuideOutput? { + return providers[id]?.guideOutput(name) + } + + fun thermometer(id: String, name: String): Thermometer? { + return providers[id]?.thermometer(name) } fun camera(name: String): Camera? { - return provider?.camera(name) + return providers.firstNotNullOfOrNull { it.value.camera(name) } } fun mount(name: String): Mount? { - return provider?.mount(name) + return providers.firstNotNullOfOrNull { it.value.mount(name) } } fun focuser(name: String): Focuser? { - return provider?.focuser(name) + return providers.firstNotNullOfOrNull { it.value.focuser(name) } } fun wheel(name: String): FilterWheel? { - return provider?.wheel(name) + return providers.firstNotNullOfOrNull { it.value.wheel(name) } } fun gps(name: String): GPS? { - return provider?.gps(name) + return providers.firstNotNullOfOrNull { it.value.gps(name) } } fun guideOutput(name: String): GuideOutput? { - return provider?.guideOutput(name) + return providers.firstNotNullOfOrNull { it.value.guideOutput(name) } } fun thermometer(name: String): Thermometer? { - return provider?.thermometer(name) + return providers.firstNotNullOfOrNull { it.value.thermometer(name) } } fun device(name: String): Device? { diff --git a/api/src/main/kotlin/nebulosa/api/focusers/FocuserSerializer.kt b/api/src/main/kotlin/nebulosa/api/focusers/FocuserSerializer.kt index aa0295dff..5162f3dd1 100644 --- a/api/src/main/kotlin/nebulosa/api/focusers/FocuserSerializer.kt +++ b/api/src/main/kotlin/nebulosa/api/focusers/FocuserSerializer.kt @@ -11,6 +11,8 @@ class FocuserSerializer : StdSerializer(Focuser::class.java) { override fun serialize(value: Focuser, gen: JsonGenerator, provider: SerializerProvider) { gen.writeStartObject() + gen.writeStringField("sender", value.sender.id) + gen.writeStringField("id", value.id) gen.writeStringField("name", value.name) gen.writeBooleanField("connected", value.connected) gen.writeBooleanField("moving", value.moving) diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuideOutputSerializer.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuideOutputSerializer.kt index 3b39c21a2..1d9c8d878 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/GuideOutputSerializer.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuideOutputSerializer.kt @@ -11,6 +11,8 @@ class GuideOutputSerializer : StdSerializer(GuideOutput::class.java override fun serialize(value: GuideOutput, gen: JsonGenerator, provider: SerializerProvider) { gen.writeStartObject() + gen.writeStringField("sender", value.sender.id) + gen.writeStringField("id", value.id) gen.writeStringField("name", value.name) gen.writeBooleanField("connected", value.connected) gen.writeBooleanField("canPulseGuide", value.canPulseGuide) diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountSerializer.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountSerializer.kt index db207b430..9fecfbc99 100644 --- a/api/src/main/kotlin/nebulosa/api/mounts/MountSerializer.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountSerializer.kt @@ -16,6 +16,8 @@ class MountSerializer : StdSerializer(Mount::class.java) { override fun serialize(value: Mount, gen: JsonGenerator, provider: SerializerProvider) { gen.writeStartObject() + gen.writeStringField("sender", value.sender.id) + gen.writeStringField("id", value.id) gen.writeStringField("name", value.name) gen.writeBooleanField("connected", value.connected) gen.writeBooleanField("slewing", value.slewing) diff --git a/api/src/main/kotlin/nebulosa/api/wheels/WheelSerializer.kt b/api/src/main/kotlin/nebulosa/api/wheels/WheelSerializer.kt index 35c8da51c..31510ba08 100644 --- a/api/src/main/kotlin/nebulosa/api/wheels/WheelSerializer.kt +++ b/api/src/main/kotlin/nebulosa/api/wheels/WheelSerializer.kt @@ -11,6 +11,8 @@ class WheelSerializer : StdSerializer(FilterWheel::class.java) { override fun serialize(value: FilterWheel, gen: JsonGenerator, provider: SerializerProvider) { gen.writeStartObject() + gen.writeStringField("sender", value.sender.id) + gen.writeStringField("id", value.id) gen.writeStringField("name", value.name) gen.writeBooleanField("connected", value.connected) gen.writeNumberField("count", value.count) diff --git a/desktop/src/app/alignment/alignment.component.ts b/desktop/src/app/alignment/alignment.component.ts index d8c103188..89c566d04 100644 --- a/desktop/src/app/alignment/alignment.component.ts +++ b/desktop/src/app/alignment/alignment.component.ts @@ -82,7 +82,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { app.title = 'Alignment' electron.on('CAMERA.UPDATED', event => { - if (event.device.name === this.camera.name) { + if (event.device.id === this.camera.id) { ngZone.run(() => { Object.assign(this.camera, event.device) }) @@ -98,7 +98,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { electron.on('CAMERA.DETACHED', event => { ngZone.run(() => { - const index = this.cameras.findIndex(e => e.name === event.device.name) + const index = this.cameras.findIndex(e => e.id === event.device.id) if (index >= 0) { if (this.cameras[index] === this.camera) { @@ -112,7 +112,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { }) electron.on('MOUNT.UPDATED', event => { - if (event.device.name === this.mount.name) { + if (event.device.id === this.mount.id) { ngZone.run(() => { Object.assign(this.mount, event.device) }) @@ -128,7 +128,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { electron.on('MOUNT.DETACHED', event => { ngZone.run(() => { - const index = this.mounts.findIndex(e => e.name === event.device.name) + const index = this.mounts.findIndex(e => e.id === event.device.id) if (index >= 0) { if (this.mounts[index] === this.mount) { @@ -142,7 +142,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { }) electron.on('GUIDE_OUTPUT.UPDATED', event => { - if (event.device.name === this.guideOutput.name) { + if (event.device.id === this.guideOutput.id) { ngZone.run(() => { Object.assign(this.guideOutput, event.device) }) @@ -158,7 +158,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { electron.on('GUIDE_OUTPUT.DETACHED', event => { ngZone.run(() => { - const index = this.guideOutputs.findIndex(e => e.name === event.device.name) + const index = this.guideOutputs.findIndex(e => e.id === event.device.id) if (index >= 0) { if (this.guideOutputs[index] === this.guideOutput) { diff --git a/desktop/src/app/camera/camera.component.ts b/desktop/src/app/camera/camera.component.ts index 79d11f289..7bc2c8e62 100644 --- a/desktop/src/app/camera/camera.component.ts +++ b/desktop/src/app/camera/camera.component.ts @@ -137,7 +137,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { if (app) app.title = 'Camera' electron.on('CAMERA.UPDATED', event => { - if (event.device.name === this.camera.name) { + if (event.device.id === this.camera.id) { ngZone.run(() => { Object.assign(this.camera, event.device) this.update() @@ -146,7 +146,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { }) electron.on('CAMERA.DETACHED', event => { - if (event.device.name === this.camera.name) { + if (event.device.id === this.camera.id) { ngZone.run(() => { Object.assign(this.camera, EMPTY_CAMERA) }) @@ -154,7 +154,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { }) electron.on('CAMERA.CAPTURE_ELAPSED', event => { - if (event.camera.name === this.camera.name) { + if (event.camera.id === this.camera.id) { ngZone.run(() => { this.running = this.cameraExposure.handleCameraCaptureEvent(event) }) diff --git a/desktop/src/app/filterwheel/filterwheel.component.ts b/desktop/src/app/filterwheel/filterwheel.component.ts index 67744a045..28c16648b 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.ts +++ b/desktop/src/app/filterwheel/filterwheel.component.ts @@ -61,7 +61,7 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { if (app) app.title = 'Filter Wheel' electron.on('WHEEL.UPDATED', event => { - if (event.device.name === this.wheel.name) { + if (event.device.id === this.wheel.id) { ngZone.run(() => { const wasConnected = this.wheel.connected Object.assign(this.wheel, event.device) @@ -71,7 +71,7 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { }) electron.on('WHEEL.DETACHED', event => { - if (event.device.name === this.wheel.name) { + if (event.device.id === this.wheel.id) { ngZone.run(() => { Object.assign(this.wheel, EMPTY_WHEEL) }) diff --git a/desktop/src/app/flat-wizard/flat-wizard.component.ts b/desktop/src/app/flat-wizard/flat-wizard.component.ts index 4ba1e8ee9..1ae306563 100644 --- a/desktop/src/app/flat-wizard/flat-wizard.component.ts +++ b/desktop/src/app/flat-wizard/flat-wizard.component.ts @@ -63,7 +63,7 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { app.title = 'Flat Wizard' electron.on('FLAT_WIZARD.ELAPSED', event => { - if (event.state === 'EXPOSURING' && event.capture && event.capture.camera?.name === this.camera?.name) { + if (event.state === 'EXPOSURING' && event.capture && event.capture.camera?.id === this.camera?.id) { ngZone.run(() => { this.running = this.cameraExposure.handleCameraCaptureEvent(event.capture!, true) }) @@ -87,7 +87,7 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { }) electron.on('CAMERA.UPDATED', event => { - if (event.device.name === this.camera.name) { + if (event.device.id === this.camera.id) { ngZone.run(() => { Object.assign(this.camera, event.device) this.cameraChanged() @@ -104,7 +104,7 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { electron.on('CAMERA.DETACHED', event => { ngZone.run(() => { - const index = this.cameras.findIndex(e => e.name === event.device.name) + const index = this.cameras.findIndex(e => e.id === event.device.id) if (index >= 0) { if (this.cameras[index] === this.camera) { @@ -118,7 +118,7 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { }) electron.on('WHEEL.UPDATED', event => { - if (event.device.name === this.wheel.name) { + if (event.device.id === this.wheel.id) { ngZone.run(() => { Object.assign(this.wheel, event.device) this.wheelChanged() @@ -135,7 +135,7 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { electron.on('WHEEL.DETACHED', event => { ngZone.run(() => { - const index = this.wheels.findIndex(e => e.name === event.device.name) + const index = this.wheels.findIndex(e => e.id === event.device.id) if (index >= 0) { if (this.wheels[index] === this.wheel) { diff --git a/desktop/src/app/focuser/focuser.component.ts b/desktop/src/app/focuser/focuser.component.ts index f0fb4e02c..1a6d91ac1 100644 --- a/desktop/src/app/focuser/focuser.component.ts +++ b/desktop/src/app/focuser/focuser.component.ts @@ -42,7 +42,7 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { app.title = 'Focuser' electron.on('FOCUSER.UPDATED', event => { - if (event.device.name === this.focuser.name) { + if (event.device.id === this.focuser.id) { ngZone.run(() => { Object.assign(this.focuser, event.device) this.update() @@ -51,7 +51,7 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { }) electron.on('FOCUSER.DETACHED', event => { - if (event.device.name === this.focuser.name) { + if (event.device.id === this.focuser.id) { ngZone.run(() => { Object.assign(this.focuser, EMPTY_FOCUSER) }) diff --git a/desktop/src/app/guider/guider.component.ts b/desktop/src/app/guider/guider.component.ts index a8036664f..70ff30f5e 100644 --- a/desktop/src/app/guider/guider.component.ts +++ b/desktop/src/app/guider/guider.component.ts @@ -221,7 +221,7 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { title.setTitle('Guider') electron.on('GUIDE_OUTPUT.UPDATED', event => { - if (event.device.name === this.guideOutput?.name) { + if (event.device.id === this.guideOutput?.id) { ngZone.run(() => { Object.assign(this.guideOutput!, event.device) this.update() @@ -237,7 +237,7 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { electron.on('GUIDE_OUTPUT.DETACHED', event => { ngZone.run(() => { - const index = this.guideOutputs.findIndex(e => e.name === event.device.name) + const index = this.guideOutputs.findIndex(e => e.id === event.device.id) if (index >= 0) this.guideOutputs.splice(index, 1) }) }) diff --git a/desktop/src/app/home/home.component.html b/desktop/src/app/home/home.component.html index 17a313da0..9e20cab1b 100644 --- a/desktop/src/app/home/home.component.html +++ b/desktop/src/app/home/home.component.html @@ -1,40 +1,38 @@
-
+
- +
- {{ item.host }} + {{ item?.name }}
-
-
- {{ item.host }}:{{ item.port }} - +
+
+ {{ item.name }} + {{ item.host }}:{{ item.port }} +
+ + {{ (item.connectedAt | date:'yyyy-MM-dd HH:mm:ss') ?? 'never' }} +
-
- - {{ item.connectedAt | date:'yyyy-MM-dd HH:mm:ss' }} +
+ +
- + -
-
- - - - - - + + +
@@ -152,5 +150,37 @@
+ +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ +
+
+ + + +
+ \ No newline at end of file diff --git a/desktop/src/app/home/home.component.ts b/desktop/src/app/home/home.component.ts index c8e76a91b..73d9fc422 100644 --- a/desktop/src/app/home/home.component.ts +++ b/desktop/src/app/home/home.component.ts @@ -1,7 +1,6 @@ import { AfterContentInit, Component, HostListener, NgZone, OnDestroy, ViewChild } from '@angular/core' import path from 'path' import { MenuItem, MessageService } from 'primeng/api' -import { AutoCompleteCompleteEvent } from 'primeng/autocomplete' import { DeviceListMenuComponent } from '../../shared/components/device-list-menu/device-list-menu.component' import { DialogMenuComponent } from '../../shared/components/dialog-menu/dialog-menu.component' import { ApiService } from '../../shared/services/api.service' @@ -11,7 +10,7 @@ import { PreferenceService } from '../../shared/services/preference.service' import { Camera } from '../../shared/types/camera.types' import { Device } from '../../shared/types/device.types' import { Focuser } from '../../shared/types/focuser.types' -import { ConnectionDetails, HomeWindowType } from '../../shared/types/home.types' +import { CONNECTION_TYPES, ConnectionDetails, EMPTY_CONNECTION_DETAILS, HomeWindowType } from '../../shared/types/home.types' import { Mount } from '../../shared/types/mount.types' import { FilterWheel } from '../../shared/types/wheel.types' import { deviceComparator } from '../../shared/utils/comparators' @@ -37,24 +36,11 @@ export class HomeComponent implements AfterContentInit, OnDestroy { @ViewChild('imageMenu') private readonly imageMenu!: DeviceListMenuComponent - connected = false - lastConnectedHosts: ConnectionDetails[] = [] - connection: ConnectionDetails - - readonly connectionTypeModel: MenuItem[] = [ - { - label: 'INDI', - command: () => { - this.connection.type = 'INDI' - }, - }, - { - label: 'ASCOM Alpaca', - command: () => { - this.connection.type = 'ALPACA' - }, - } - ] + readonly connectionTypes = Array.from(CONNECTION_TYPES) + showConnectionDialog = false + readonly connections: ConnectionDetails[] = [] + connection?: ConnectionDetails + newConnection?: [ConnectionDetails, ConnectionDetails | undefined] cameras: Camera[] = [] mounts: Mount[] = [] @@ -64,6 +50,10 @@ export class HomeComponent implements AfterContentInit, OnDestroy { rotators: Camera[] = [] switches: Camera[] = [] + get connected() { + return !!this.connection && this.connection.connected + } + get hasCamera() { return this.cameras.length > 0 } @@ -159,7 +149,7 @@ export class HomeComponent implements AfterContentInit, OnDestroy { return this.cameras.push(device) }, (device) => { - this.cameras.splice(this.cameras.findIndex(e => e.name === device.name), 1) + this.cameras.splice(this.cameras.findIndex(e => e.id === device.id), 1) return this.cameras.length }, ) @@ -169,7 +159,7 @@ export class HomeComponent implements AfterContentInit, OnDestroy { return this.mounts.push(device) }, (device) => { - this.mounts.splice(this.mounts.findIndex(e => e.name === device.name), 1) + this.mounts.splice(this.mounts.findIndex(e => e.id === device.id), 1) return this.mounts.length }, ) @@ -179,7 +169,7 @@ export class HomeComponent implements AfterContentInit, OnDestroy { return this.focusers.push(device) }, (device) => { - this.focusers.splice(this.focusers.findIndex(e => e.name === device.name), 1) + this.focusers.splice(this.focusers.findIndex(e => e.id === device.id), 1) return this.focusers.length }, ) @@ -189,14 +179,14 @@ export class HomeComponent implements AfterContentInit, OnDestroy { return this.wheels.push(device) }, (device) => { - this.wheels.splice(this.wheels.findIndex(e => e.name === device.name), 1) + this.wheels.splice(this.wheels.findIndex(e => e.id === device.id), 1) return this.wheels.length }, ) - this.lastConnectedHosts = this.preference.lastConnectedHosts.get() - this.connection = Object.assign({}, this.lastConnectedHosts[0]) - this.connection.type ??= 'INDI' + this.connections = preference.connections.get() + this.connections.forEach(e => e.connected = false) + this.connection = this.connections[0] } async ngAfterContentInit() { @@ -211,49 +201,57 @@ export class HomeComponent implements AfterContentInit, OnDestroy { @HostListener('window:unload') ngOnDestroy() { } - hostChanged(event: string | ConnectionDetails) { - if (typeof event === 'string') { - this.connection.host = event - } else { - Object.assign(this.connection, event) - } + addConnection() { + this.newConnection = [Object.assign({}, EMPTY_CONNECTION_DETAILS), undefined] + this.showConnectionDialog = true } - removeConnection(connection: ConnectionDetails, event: MouseEvent) { - const { host, port } = connection - const index = this.lastConnectedHosts.findIndex(e => e.host === host && e.port === port) - - if (index >= 0) { - this.lastConnectedHosts.splice(index, 1) - this.preference.lastConnectedHosts.set(this.lastConnectedHosts) - } - + editConnection(connection: ConnectionDetails, event: MouseEvent) { + this.newConnection = [Object.assign({}, connection), connection] + this.showConnectionDialog = true event.stopImmediatePropagation() } - async connect() { - try { - if (this.connected) { - await this.api.disconnect() - } else { - let { host, port, type } = this.connection + deleteConnection(connection: ConnectionDetails, event: MouseEvent) { + const index = this.connections.findIndex(e => e === connection) - host ||= 'localhost' - port ||= 7624 - type || 'INDI' + if (index >= 0 && !connection.connected) { + this.connections.splice(index, 1) - await this.api.connect(host, port, type) + if (connection === this.connection) { + this.connection = undefined + } - const index = this.lastConnectedHosts.findIndex(e => e.host === host && e.port === port) + this.preference.connections.set(this.connections) + } - if (index >= 0) { - this.lastConnectedHosts.splice(index, 1) - } + event.stopImmediatePropagation() + } + + saveConnection() { + if (this.newConnection) { + // Edit. + if (this.newConnection[1]) { + Object.assign(this.newConnection[1], this.newConnection[0]) + } + // New. + else { + this.connections.push(this.newConnection[0]) + } + } - this.lastConnectedHosts.splice(0, 0, this.connection) - this.lastConnectedHosts[0].connectedAt = Date.now() + this.preference.connections.set(this.connections) - this.preference.lastConnectedHosts.set(this.lastConnectedHosts) + this.newConnection = undefined + this.showConnectionDialog = false + } + + async connect() { + try { + if (this.connection && this.connection.connected) { + await this.api.disconnect(this.connection.id!) + } else if (this.connection) { + this.connection.id = await this.api.connect(this.connection.host, this.connection.port, this.connection.type) } } catch (e) { console.error(e) @@ -264,10 +262,6 @@ export class HomeComponent implements AfterContentInit, OnDestroy { } } - filterLastConnected(event: AutoCompleteCompleteEvent) { - - } - private openDevice(type: K) { this.deviceModel.length = 0 @@ -370,20 +364,28 @@ export class HomeComponent implements AfterContentInit, OnDestroy { } private async updateConnection() { - try { - this.connected = await this.api.connectionStatus() - } catch { - this.connected = false - } - - if (!this.connected) { - this.cameras = [] - this.mounts = [] - this.focusers = [] - this.wheels = [] - this.domes = [] - this.rotators = [] - this.switches = [] + if (this.connection && this.connection.id) { + try { + const connected = await this.api.connectionStatus(this.connection.id!) + + if (connected && !this.connection.connected) { + this.connection.connectedAt = Date.now() + this.preference.connections.set(this.connections) + this.connection.connected = true + } else if (!connected) { + this.connection.connected = false + } + } catch { + this.connection.connected = false + + this.cameras = [] + this.mounts = [] + this.focusers = [] + this.wheels = [] + this.domes = [] + this.rotators = [] + this.switches = [] + } } } } diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 37e660625..35ba98849 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -367,7 +367,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { app.title = 'Image' electron.on('CAMERA.CAPTURE_ELAPSED', async (event) => { - if (event.state === 'EXPOSURE_FINISHED' && event.camera.name === this.imageData.camera?.name) { + if (event.state === 'EXPOSURE_FINISHED' && event.camera.id === this.imageData.camera?.id) { await this.closeImage(event.savePath !== this.imageData.path) ngZone.run(() => { diff --git a/desktop/src/app/indi/indi.component.ts b/desktop/src/app/indi/indi.component.ts index 0c35a5a44..a6867b318 100644 --- a/desktop/src/app/indi/indi.component.ts +++ b/desktop/src/app/indi/indi.component.ts @@ -51,7 +51,7 @@ export class INDIComponent implements AfterViewInit, OnDestroy { }) electron.on('DEVICE.MESSAGE_RECEIVED', event => { - if (this.device && event.device?.name === this.device.name) { + if (this.device && event.device?.id === this.device.id) { ngZone.run(() => { this.messages.splice(0, 0, event.message!) }) diff --git a/desktop/src/app/mount/mount.component.ts b/desktop/src/app/mount/mount.component.ts index c1663bb91..2434fa01a 100644 --- a/desktop/src/app/mount/mount.component.ts +++ b/desktop/src/app/mount/mount.component.ts @@ -212,7 +212,7 @@ export class MountComponent implements AfterContentInit, OnDestroy { app.title = 'Mount' electron.on('MOUNT.UPDATED', event => { - if (event.device.name === this.mount?.name) { + if (event.device.id === this.mount?.id) { ngZone.run(() => { const wasConnected = this.mount.connected Object.assign(this.mount, event.device) @@ -226,7 +226,7 @@ export class MountComponent implements AfterContentInit, OnDestroy { }) electron.on('MOUNT.DETACHED', event => { - if (event.device.name === this.mount?.name) { + if (event.device.id === this.mount?.id) { ngZone.run(() => { Object.assign(this.mount, EMPTY_MOUNT) }) diff --git a/desktop/src/app/sequencer/sequencer.component.ts b/desktop/src/app/sequencer/sequencer.component.ts index 8c97c9729..c344a2c6d 100644 --- a/desktop/src/app/sequencer/sequencer.component.ts +++ b/desktop/src/app/sequencer/sequencer.component.ts @@ -124,7 +124,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { electron.on('CAMERA.UPDATED', event => { ngZone.run(() => { - const camera = this.cameras.find(e => e.name === event.device.name) + const camera = this.cameras.find(e => e.id === event.device.id) if (camera) { Object.assign(camera, event.device) @@ -134,7 +134,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { electron.on('WHEEL.UPDATED', event => { ngZone.run(() => { - const wheel = this.wheels.find(e => e.name === event.device.name) + const wheel = this.wheels.find(e => e.id === event.device.id) if (wheel) { Object.assign(wheel, event.device) @@ -144,7 +144,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { electron.on('FOCUSER.UPDATED', event => { ngZone.run(() => { - const focuser = this.focusers.find(e => e.name === event.device.name) + const focuser = this.focusers.find(e => e.id === event.device.id) if (focuser) { Object.assign(focuser, event.device) diff --git a/desktop/src/shared/services/api.service.ts b/desktop/src/shared/services/api.service.ts index b5eaf7bfb..fc8989808 100644 --- a/desktop/src/shared/services/api.service.ts +++ b/desktop/src/shared/services/api.service.ts @@ -9,13 +9,13 @@ import { FlatWizardRequest } from '../types/flat-wizard.types' import { Focuser } from '../types/focuser.types' import { HipsSurvey } from '../types/framing.types' import { GuideDirection, GuideOutput, Guider, GuiderHistoryStep, SettleInfo } from '../types/guider.types' +import { ConnectionType } from '../types/home.types' import { CoordinateInterpolation, DetectedStar, ImageAnnotation, ImageChannel, ImageInfo, ImageSolved, SCNRProtectionMethod } from '../types/image.types' import { CelestialLocationType, Mount, SlewRate, TrackMode } from '../types/mount.types' import { SequencePlan } from '../types/sequencer.types' import { PlateSolverOptions } from '../types/settings.types' import { FilterWheel } from '../types/wheel.types' import { HttpService } from './http.service' -import { ConnectionType } from '../types/home.types' @Injectable({ providedIn: 'root' }) export class ApiService { @@ -30,15 +30,15 @@ export class ApiService { connect(host: string, port: number, type: ConnectionType) { const query = this.http.query({ host, port, type }) - return this.http.put(`connection?${query}`) + return this.http.put(`connection?${query}`) } - disconnect() { - return this.http.delete(`connection`) + disconnect(id: string) { + return this.http.delete(`connection/${id}`) } - connectionStatus() { - return this.http.get(`connection`) + connectionStatus(id: string) { + return this.http.get(`connection/${id}`) } // CAMERA diff --git a/desktop/src/shared/services/preference.service.ts b/desktop/src/shared/services/preference.service.ts index 60b186f13..6ba740c5c 100644 --- a/desktop/src/shared/services/preference.service.ts +++ b/desktop/src/shared/services/preference.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core' import { AlignmentPreference, EMPTY_ALIGNMENT_PREFERENCE } from '../types/alignment.types' import { Camera, CameraPreference, CameraStartCapture, EMPTY_CAMERA_PREFERENCE } from '../types/camera.types' -import { ConnectionDetails, EMPTY_CONNECTION_DETAILS } from '../types/home.types' +import { ConnectionDetails } from '../types/home.types' import { EMPTY_IMAGE_PREFERENCE, ImagePreference } from '../types/image.types' import { EMPTY_PLATE_SOLVER_OPTIONS, PlateSolverOptions, PlateSolverType } from '../types/settings.types' import { FilterWheel, WheelPreference } from '../types/wheel.types' @@ -63,6 +63,6 @@ export class PreferenceService { } readonly alignmentPreference = new PreferenceData(this.storage, `alignment`, () => Object.assign({}, EMPTY_ALIGNMENT_PREFERENCE)) - readonly lastConnectedHosts = new PreferenceData(this.storage, 'home.lastConnectedHosts', () => [Object.assign({}, EMPTY_CONNECTION_DETAILS)]) + readonly connections = new PreferenceData(this.storage, 'home.connections', () => []) readonly homeImageDefaultDirectory = new PreferenceData(this.storage, 'home.image.directory', '') } \ No newline at end of file diff --git a/desktop/src/shared/types/camera.types.ts b/desktop/src/shared/types/camera.types.ts index 86ceec0b4..75e188f5d 100644 --- a/desktop/src/shared/types/camera.types.ts +++ b/desktop/src/shared/types/camera.types.ts @@ -69,6 +69,8 @@ export interface Camera extends GuideOutput, Thermometer { } export const EMPTY_CAMERA: Camera = { + sender: '', + id: '', exposuring: false, hasCoolerControl: false, coolerPower: 0, diff --git a/desktop/src/shared/types/device.types.ts b/desktop/src/shared/types/device.types.ts index 5b7494536..53b60d1a7 100644 --- a/desktop/src/shared/types/device.types.ts +++ b/desktop/src/shared/types/device.types.ts @@ -9,6 +9,8 @@ export type INDIPropertyType = 'NUMBER' | 'SWITCH' | 'TEXT' export type SwitchRule = 'ONE_OF_MANY' | 'AT_MOST_ONE' | 'ANY_OF_MANY' export interface Device { + readonly sender: string + readonly id: string readonly name: string connected: boolean } diff --git a/desktop/src/shared/types/focuser.types.ts b/desktop/src/shared/types/focuser.types.ts index 43b0206f1..fbcffd8c4 100644 --- a/desktop/src/shared/types/focuser.types.ts +++ b/desktop/src/shared/types/focuser.types.ts @@ -15,6 +15,8 @@ export interface Focuser extends Device, Thermometer { } export const EMPTY_FOCUSER: Focuser = { + sender: '', + id: '', moving: false, position: 0, canAbsoluteMove: false, diff --git a/desktop/src/shared/types/guider.types.ts b/desktop/src/shared/types/guider.types.ts index c0708b291..07c74d824 100644 --- a/desktop/src/shared/types/guider.types.ts +++ b/desktop/src/shared/types/guider.types.ts @@ -61,6 +61,8 @@ export interface GuideOutput extends Device { } export const EMPTY_GUIDE_OUTPUT: GuideOutput = { + sender: '', + id: '', canPulseGuide: false, pulseGuiding: false, name: '', diff --git a/desktop/src/shared/types/home.types.ts b/desktop/src/shared/types/home.types.ts index 09a67d685..7540b32b4 100644 --- a/desktop/src/shared/types/home.types.ts +++ b/desktop/src/shared/types/home.types.ts @@ -6,14 +6,19 @@ export const CONNECTION_TYPES = ['INDI', 'ALPACA'] as const export type ConnectionType = (typeof CONNECTION_TYPES)[number] export interface ConnectionDetails { + name: string host: string port: number type: ConnectionType + connected: boolean connectedAt?: number + id?: string } export const EMPTY_CONNECTION_DETAILS: ConnectionDetails = { + name: '', host: 'localhost', port: 7624, - type: 'INDI' + type: 'INDI', + connected: false } diff --git a/desktop/src/shared/types/mount.types.ts b/desktop/src/shared/types/mount.types.ts index 2370d20b4..ddb5dbc65 100644 --- a/desktop/src/shared/types/mount.types.ts +++ b/desktop/src/shared/types/mount.types.ts @@ -38,6 +38,8 @@ export interface Mount extends EquatorialCoordinate, GPS, GuideOutput, Parkable } export const EMPTY_MOUNT: Mount = { + sender: '', + id: '', slewing: false, tracking: false, canAbort: false, diff --git a/desktop/src/shared/types/wheel.types.ts b/desktop/src/shared/types/wheel.types.ts index 1cd21040a..1a635506c 100644 --- a/desktop/src/shared/types/wheel.types.ts +++ b/desktop/src/shared/types/wheel.types.ts @@ -10,6 +10,8 @@ export interface FilterWheel extends Device { } export const EMPTY_WHEEL: FilterWheel = { + sender: '', + id: '', count: 0, position: 0, moving: false, diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt index ee69c2a17..37f62365d 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt @@ -17,12 +17,13 @@ import nebulosa.indi.device.guide.GuideOutput import nebulosa.indi.device.guide.GuideOutputAttached import nebulosa.indi.device.mount.Mount import nebulosa.indi.device.thermometer.Thermometer +import nebulosa.indi.protocol.INDIProtocol import nebulosa.log.loggerFor import okhttp3.OkHttpClient +import java.util.* class AlpacaClient( - host: String, - port: Int, + host: String, port: Int, httpClient: OkHttpClient? = null, ) : INDIDeviceProvider { @@ -31,6 +32,8 @@ class AlpacaClient( private val cameras = HashMap() private val guideOutputs = HashMap() + override val id = UUID.randomUUID().toString() + override fun registerDeviceEventHandler(handler: DeviceEventHandler) { handlers.add(handler) } @@ -39,6 +42,8 @@ class AlpacaClient( handlers.remove(handler) } + override fun sendMessageToServer(message: INDIProtocol) {} + internal fun fireOnEventReceived(event: DeviceEvent<*>) { handlers.forEach { it.onEventReceived(event) } } @@ -127,7 +132,7 @@ class AlpacaClient( internal fun registerGuideOutput(device: GuideOutput) { if (device is ASCOMDevice) { - guideOutputs[device.uid] = device + guideOutputs[device.id] = device fireOnEventReceived(GuideOutputAttached(device)) } } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt index d117bfba9..e7fe3292f 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt @@ -16,7 +16,7 @@ abstract class ASCOMDevice : Device { protected abstract val device: ConfiguredDevice protected abstract val service: AlpacaDeviceService - protected abstract val client: AlpacaClient + abstract override val sender: AlpacaClient @Suppress("PropertyName") @JvmField protected val LOG = loggerFor(javaClass) @@ -24,7 +24,7 @@ abstract class ASCOMDevice : Device { override val name get() = device.name - val uid + override val id get() = device.uid @Volatile final override var connected = false @@ -64,7 +64,7 @@ abstract class ASCOMDevice : Device { synchronized(messages) { messages.addFirst(text) - client.fireOnEventReceived(DeviceMessageReceived(this, text)) + sender.fireOnEventReceived(DeviceMessageReceived(this, text)) if (messages.size > 100) { messages.removeLast() @@ -110,7 +110,7 @@ abstract class ASCOMDevice : Device { connected = value if (value) { - client.fireOnEventReceived(DeviceConnected(this)) + sender.fireOnEventReceived(DeviceConnected(this)) onConnected() @@ -119,7 +119,7 @@ abstract class ASCOMDevice : Device { refresher!!.start() } } else { - client.fireOnEventReceived(DeviceDisconnected(this)) + sender.fireOnEventReceived(DeviceDisconnected(this)) onDisconnected() diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt index 78c3886db..a45c4bde2 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt @@ -37,7 +37,7 @@ import kotlin.math.min data class ASCOMCamera( override val device: ConfiguredDevice, override val service: AlpacaCameraService, - override val client: AlpacaClient, + override val sender: AlpacaClient, ) : ASCOMDevice(), Camera { @Volatile override var exposuring = false @@ -214,7 +214,7 @@ data class ASCOMCamera( if (durationInMilliseconds > 0) { pulseGuiding = true - client.fireOnEventReceived(GuideOutputPulsingChanged(this)) + sender.fireOnEventReceived(GuideOutputPulsingChanged(this)) } } @@ -234,9 +234,6 @@ data class ASCOMCamera( pulseGuide(PulseGuideDirection.WEST, duration) } - override fun sendMessageToServer(message: INDIProtocol) { - } - override fun snoop(devices: Iterable) { for (device in devices) { if (device is Mount) mount = device @@ -380,8 +377,8 @@ data class ASCOMCamera( } } - if (prevExposuring != exposuring) client.fireOnEventReceived(CameraExposuringChanged(this)) - if (prevExposureState != exposureState) client.fireOnEventReceived(CameraExposureStateChanged(this, prevExposureState)) + if (prevExposuring != exposuring) sender.fireOnEventReceived(CameraExposuringChanged(this)) + if (prevExposureState != exposureState) sender.fireOnEventReceived(CameraExposureStateChanged(this, prevExposureState)) if (exposuring) { service.percentCompleted(device.number).doRequest { @@ -390,11 +387,11 @@ data class ASCOMCamera( } if (exposureState == PropertyState.IDLE && (prevExposureState == PropertyState.BUSY || exposuring)) { - client.fireOnEventReceived(CameraExposureAborted(this)) + sender.fireOnEventReceived(CameraExposureAborted(this)) } else if (exposureState == PropertyState.OK && prevExposureState == PropertyState.BUSY) { - client.fireOnEventReceived(CameraExposureFinished(this)) + sender.fireOnEventReceived(CameraExposureFinished(this)) } else if (exposureState == PropertyState.ALERT && prevExposureState != PropertyState.ALERT) { - client.fireOnEventReceived(CameraExposureFailed(this)) + sender.fireOnEventReceived(CameraExposureFailed(this)) } } } @@ -406,7 +403,7 @@ data class ASCOMCamera( binX = x.value binY = y.value - client.fireOnEventReceived(CameraBinChanged(this)) + sender.fireOnEventReceived(CameraBinChanged(this)) } } } @@ -419,7 +416,7 @@ data class ASCOMCamera( gainMax = max.value gain = max(gainMin, min(gain, gainMax)) - client.fireOnEventReceived(CameraGainMinMaxChanged(this)) + sender.fireOnEventReceived(CameraGainMinMaxChanged(this)) } } } @@ -429,7 +426,7 @@ data class ASCOMCamera( if (it.value != gain) { gain = it.value - client.fireOnEventReceived(CameraGainChanged(this)) + sender.fireOnEventReceived(CameraGainChanged(this)) } } } @@ -441,7 +438,7 @@ data class ASCOMCamera( offsetMax = max.value offset = max(offsetMin, min(offset, offsetMax)) - client.fireOnEventReceived(CameraOffsetMinMaxChanged(this)) + sender.fireOnEventReceived(CameraOffsetMinMaxChanged(this)) } } } @@ -451,7 +448,7 @@ data class ASCOMCamera( if (it.value != offset) { offset = it.value - client.fireOnEventReceived(CameraOffsetChanged(this)) + sender.fireOnEventReceived(CameraOffsetChanged(this)) } } } @@ -473,7 +470,7 @@ data class ASCOMCamera( maxY = height - 1 if (!processFrame()) { - client.fireOnEventReceived(CameraFrameChanged(this)) + sender.fireOnEventReceived(CameraFrameChanged(this)) } } } @@ -490,7 +487,7 @@ data class ASCOMCamera( this.x = x.value this.y = y.value - client.fireOnEventReceived(CameraFrameChanged(this)) + sender.fireOnEventReceived(CameraFrameChanged(this)) return true } @@ -508,7 +505,7 @@ data class ASCOMCamera( if (coolerPower != it.value) { coolerPower = it.value - client.fireOnEventReceived(CameraCoolerPowerChanged(this)) + sender.fireOnEventReceived(CameraCoolerPowerChanged(this)) } } } @@ -518,7 +515,7 @@ data class ASCOMCamera( if (cooler != it.value) { cooler = it.value - client.fireOnEventReceived(CameraCoolerChanged(this)) + sender.fireOnEventReceived(CameraCoolerChanged(this)) } } } @@ -531,7 +528,7 @@ data class ASCOMCamera( pixelSizeX = x.value pixelSizeY = y.value - client.fireOnEventReceived(CameraPixelSizeChanged(this)) + sender.fireOnEventReceived(CameraPixelSizeChanged(this)) } } } @@ -544,7 +541,7 @@ data class ASCOMCamera( cfaOffsetX = x.value cfaOffsetY = y.value - client.fireOnEventReceived(CameraCfaChanged(this)) + sender.fireOnEventReceived(CameraCfaChanged(this)) } } } @@ -554,7 +551,7 @@ data class ASCOMCamera( service.readoutModes(device.number).doRequest { frameFormats = it.value.toList() - client.fireOnEventReceived(CameraFrameFormatsChanged(this)) + sender.fireOnEventReceived(CameraFrameFormatsChanged(this)) } } @@ -564,7 +561,7 @@ data class ASCOMCamera( exposureMin = Duration.ofNanos((min.value * NANO_SECONDS).toLong()) exposureMax = Duration.ofNanos((max.value * NANO_SECONDS).toLong()) - client.fireOnEventReceived(CameraExposureMinMaxChanged(this)) + sender.fireOnEventReceived(CameraExposureMinMaxChanged(this)) } } } @@ -574,7 +571,7 @@ data class ASCOMCamera( if (it.value != canAbort) { canAbort = it.value - client.fireOnEventReceived(CameraCanAbortChanged(this)) + sender.fireOnEventReceived(CameraCanAbortChanged(this)) } } @@ -582,7 +579,7 @@ data class ASCOMCamera( if (it.value != hasCoolerControl) { hasCoolerControl = it.value - client.fireOnEventReceived(CameraCoolerControlChanged(this)) + sender.fireOnEventReceived(CameraCoolerControlChanged(this)) } } @@ -590,7 +587,7 @@ data class ASCOMCamera( if (it.value != canPulseGuide) { canPulseGuide = it.value - client.registerGuideOutput(this) + sender.registerGuideOutput(this) LOG.info("guide output attached: {}", name) } @@ -601,8 +598,8 @@ data class ASCOMCamera( canSetTemperature = it.value hasCooler = canSetTemperature - client.fireOnEventReceived(CameraHasCoolerChanged(this)) - client.fireOnEventReceived(CameraCanSetTemperatureChanged(this)) + sender.fireOnEventReceived(CameraHasCoolerChanged(this)) + sender.fireOnEventReceived(CameraCanSetTemperatureChanged(this)) } } } @@ -688,7 +685,7 @@ data class ASCOMCamera( val fits = Fits() fits.add(hdu) - client.fireOnEventReceived(CameraFrameCaptured(this, null, fits, false)) + sender.fireOnEventReceived(CameraFrameCaptured(this, null, fits, false)) } ?: LOG.error("image body is null. device={}", name) } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt index 0b88845a6..70d4c33ba 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt @@ -2,9 +2,18 @@ package nebulosa.indi.client import nebulosa.indi.client.connection.INDIProccessConnection import nebulosa.indi.client.connection.INDISocketConnection -import nebulosa.indi.client.device.DeviceProtocolHandler +import nebulosa.indi.client.device.FilterWheelDevice +import nebulosa.indi.client.device.FocuserDevice +import nebulosa.indi.client.device.GPSDevice +import nebulosa.indi.client.device.INDIDeviceProtocolHandler +import nebulosa.indi.client.device.camera.AsiCamera +import nebulosa.indi.client.device.camera.CameraDevice +import nebulosa.indi.client.device.camera.SVBonyCamera +import nebulosa.indi.client.device.camera.SimCamera +import nebulosa.indi.client.device.mount.IoptronV3Mount +import nebulosa.indi.client.device.mount.MountDevice +import nebulosa.indi.device.Device import nebulosa.indi.device.INDIDeviceProvider -import nebulosa.indi.device.MessageSender import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.filterwheel.FilterWheel import nebulosa.indi.device.focuser.Focuser @@ -17,8 +26,9 @@ import nebulosa.indi.protocol.INDIProtocol import nebulosa.indi.protocol.io.INDIConnection import nebulosa.log.debug import nebulosa.log.loggerFor +import java.util.* -class INDIClient(private val connection: INDIConnection) : DeviceProtocolHandler(), MessageSender, INDIDeviceProvider { +class INDIClient(private val connection: INDIConnection) : INDIDeviceProtocolHandler(), INDIDeviceProvider { constructor( host: String, @@ -29,12 +39,34 @@ class INDIClient(private val connection: INDIConnection) : DeviceProtocolHandler process: Process, ) : this(INDIProccessConnection(process)) + override val id = UUID.randomUUID().toString() + override val isClosed get() = !connection.isOpen override val input get() = connection.input + override fun newCamera(message: INDIProtocol, executable: String): Camera { + return CAMERAS[executable]?.create(this, message.device) ?: CameraDevice(this, message.device) + } + + override fun newMount(message: INDIProtocol, executable: String): Mount { + return MOUNTS[executable]?.create(this, message.device) ?: MountDevice(this, message.device) + } + + override fun newFocuser(message: INDIProtocol): Focuser { + return FocuserDevice(this, message.device) + } + + override fun newFilterWheel(message: INDIProtocol): FilterWheel { + return FilterWheelDevice(this, message.device) + } + + override fun newGPS(message: INDIProtocol): GPS { + return GPSDevice(this, message.device) + } + override fun start() { super.start() sendMessageToServer(GetProperties()) @@ -110,5 +142,24 @@ class INDIClient(private val connection: INDIConnection) : DeviceProtocolHandler companion object { @JvmStatic private val LOG = loggerFor() + + @JvmStatic private val CAMERAS = mapOf( + "indi_asi_ccd" to AsiCamera::class.java, + "indi_asi_single_ccd" to AsiCamera::class.java, + "indi_svbony_ccd" to SVBonyCamera::class.java, + "indi_sv305_ccd" to SVBonyCamera::class.java, // legacy name. + "indi_simulator_ccd" to SimCamera::class.java, + "indi_simulator_guide" to SimCamera::class.java, + ) + + @JvmStatic private val MOUNTS = mapOf( + "indi_ioptronv3_telescope" to IoptronV3Mount::class.java, + ) + + @JvmStatic + fun Class.create(handler: INDIClient, name: String): T { + return getConstructor(INDIClient::class.java, String::class.java) + .newInstance(handler, name) + } } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/connection/INDISocketConnection.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/connection/INDISocketConnection.kt index 2ae819e42..d372069ce 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/connection/INDISocketConnection.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/connection/INDISocketConnection.kt @@ -12,10 +12,10 @@ class INDISocketConnection(private val socket: Socket) : INDIConnection { socket.connect(InetSocketAddress(host, port), 30000) } - val host: String + val host get() = socket.localAddress.hostName - val port: Int + val port get() = socket.localPort override val input by lazy { INDIProtocolFactory.createInputStream(socket.getInputStream()) } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt index a1d0675f2..945746e6b 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt @@ -1,5 +1,6 @@ package nebulosa.indi.client.device +import nebulosa.indi.client.INDIClient import nebulosa.indi.device.filterwheel.* import nebulosa.indi.protocol.DefNumberVector import nebulosa.indi.protocol.INDIProtocol @@ -7,9 +8,9 @@ import nebulosa.indi.protocol.NumberVector import nebulosa.indi.protocol.PropertyState internal open class FilterWheelDevice( - handler: DeviceProtocolHandler, - name: String, -) : INDIDevice(handler, name), FilterWheel { + override val sender: INDIClient, + override val name: String, +) : INDIDevice(), FilterWheel { @Volatile final override var count = 0 private set @@ -27,25 +28,25 @@ internal open class FilterWheelDevice( if (message is DefNumberVector) { count = slot.max.toInt() - slot.min.toInt() + 1 - handler.fireOnEventReceived(FilterWheelCountChanged(this)) + sender.fireOnEventReceived(FilterWheelCountChanged(this)) } if (message.state == PropertyState.ALERT) { - handler.fireOnEventReceived(FilterWheelMoveFailed(this)) + sender.fireOnEventReceived(FilterWheelMoveFailed(this)) } val prevPosition = position position = slot.value.toInt() if (prevPosition != position) { - handler.fireOnEventReceived(FilterWheelPositionChanged(this, prevPosition)) + sender.fireOnEventReceived(FilterWheelPositionChanged(this, prevPosition)) } val prevIsMoving = moving moving = message.isBusy if (prevIsMoving != moving) { - handler.fireOnEventReceived(FilterWheelMovingChanged(this)) + sender.fireOnEventReceived(FilterWheelMovingChanged(this)) } } } @@ -70,6 +71,6 @@ internal open class FilterWheelDevice( override fun toString(): String { return "FilterWheel(name=$name, slotCount=$count, position=$position," + - " moving=$moving)" + " moving=$moving)" } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt index 7809c7eb2..ace477de9 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt @@ -1,5 +1,6 @@ package nebulosa.indi.client.device +import nebulosa.indi.client.INDIClient import nebulosa.indi.device.firstOnSwitch import nebulosa.indi.device.focuser.* import nebulosa.indi.device.thermometer.ThermometerAttached @@ -7,9 +8,9 @@ import nebulosa.indi.device.thermometer.ThermometerDetached import nebulosa.indi.protocol.* internal open class FocuserDevice( - handler: DeviceProtocolHandler, - name: String, -) : INDIDevice(handler, name), Focuser { + override val sender: INDIClient, + override val name: String, +) : INDIDevice(), Focuser { @Volatile final override var moving = false private set @@ -45,19 +46,19 @@ internal open class FocuserDevice( if (message is DefSwitchVector) { canAbort = true - handler.fireOnEventReceived(FocuserCanAbortChanged(this)) + sender.fireOnEventReceived(FocuserCanAbortChanged(this)) } } "FOCUS_REVERSE_MOTION" -> { if (message is DefSwitchVector) { canReverse = true - handler.fireOnEventReceived(FocuserCanReverseChanged(this)) + sender.fireOnEventReceived(FocuserCanReverseChanged(this)) } reverse = message.firstOnSwitch().name == "INDI_ENABLED" - handler.fireOnEventReceived(FocuserReverseChanged(this)) + sender.fireOnEventReceived(FocuserReverseChanged(this)) } "FOCUS_BACKLASH_TOGGLE" -> { @@ -72,22 +73,22 @@ internal open class FocuserDevice( val prevMaxPosition = maxPosition maxPosition = message["FOCUS_RELATIVE_POSITION"]!!.max.toInt() - handler.fireOnEventReceived(FocuserCanRelativeMoveChanged(this)) + sender.fireOnEventReceived(FocuserCanRelativeMoveChanged(this)) if (prevMaxPosition != maxPosition) { - handler.fireOnEventReceived(FocuserMaxPositionChanged(this)) + sender.fireOnEventReceived(FocuserMaxPositionChanged(this)) } } if (message.state == PropertyState.ALERT) { - handler.fireOnEventReceived(FocuserMoveFailed(this)) + sender.fireOnEventReceived(FocuserMoveFailed(this)) } val prevIsMoving = moving moving = message.isBusy if (prevIsMoving != moving) { - handler.fireOnEventReceived(FocuserMovingChanged(this)) + sender.fireOnEventReceived(FocuserMovingChanged(this)) } } "ABS_FOCUS_POSITION" -> { @@ -96,36 +97,36 @@ internal open class FocuserDevice( val prevMaxPosition = maxPosition maxPosition = message["FOCUS_ABSOLUTE_POSITION"]!!.max.toInt() - handler.fireOnEventReceived(FocuserCanAbsoluteMoveChanged(this)) + sender.fireOnEventReceived(FocuserCanAbsoluteMoveChanged(this)) if (prevMaxPosition != maxPosition) { - handler.fireOnEventReceived(FocuserMaxPositionChanged(this)) + sender.fireOnEventReceived(FocuserMaxPositionChanged(this)) } } if (message.state == PropertyState.ALERT) { - handler.fireOnEventReceived(FocuserMoveFailed(this)) + sender.fireOnEventReceived(FocuserMoveFailed(this)) } val prevPosition = position position = message["FOCUS_ABSOLUTE_POSITION"]!!.value.toInt() if (prevPosition != position) { - handler.fireOnEventReceived(FocuserPositionChanged(this)) + sender.fireOnEventReceived(FocuserPositionChanged(this)) } val prevIsMoving = moving moving = message.isBusy if (prevIsMoving != moving) { - handler.fireOnEventReceived(FocuserMovingChanged(this)) + sender.fireOnEventReceived(FocuserMovingChanged(this)) } } "FOCUS_SYNC" -> { if (message is DefNumberVector) { canSync = true - handler.fireOnEventReceived(FocuserCanSyncChanged(this)) + sender.fireOnEventReceived(FocuserCanSyncChanged(this)) } } "FOCUS_BACKLASH_STEPS" -> { @@ -134,12 +135,12 @@ internal open class FocuserDevice( "FOCUS_TEMPERATURE" -> { if (message is DefNumberVector) { hasThermometer = true - handler.fireOnEventReceived(ThermometerAttached(this)) + sender.fireOnEventReceived(ThermometerAttached(this)) } temperature = message["TEMPERATURE"]!!.value - handler.fireOnEventReceived(FocuserTemperatureChanged(this)) + sender.fireOnEventReceived(FocuserTemperatureChanged(this)) } } } @@ -190,16 +191,16 @@ internal open class FocuserDevice( override fun close() { if (hasThermometer) { hasThermometer = false - handler.fireOnEventReceived(ThermometerDetached(this)) + sender.fireOnEventReceived(ThermometerDetached(this)) } } override fun toString(): String { return "Focuser(name=$name, moving=$moving, position=$position," + - " canAbsoluteMove=$canAbsoluteMove, canRelativeMove=$canRelativeMove," + - " canAbort=$canAbort, canReverse=$canReverse, reverse=$reverse," + - " canSync=$canSync, hasBacklash=$hasBacklash," + - " maxPosition=$maxPosition, hasThermometer=$hasThermometer," + - " temperature=$temperature)" + " canAbsoluteMove=$canAbsoluteMove, canRelativeMove=$canRelativeMove," + + " canAbort=$canAbort, canReverse=$canReverse, reverse=$reverse," + + " canSync=$canSync, hasBacklash=$hasBacklash," + + " maxPosition=$maxPosition, hasThermometer=$hasThermometer," + + " temperature=$temperature)" } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt index c57dfdd0f..f9616ba1d 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/GPSDevice.kt @@ -1,5 +1,6 @@ package nebulosa.indi.client.device +import nebulosa.indi.client.INDIClient import nebulosa.indi.device.gps.GPS import nebulosa.indi.device.gps.GPSCoordinateChanged import nebulosa.indi.device.gps.GPSTimeChanged @@ -12,9 +13,9 @@ import java.time.OffsetDateTime import java.time.ZoneOffset internal open class GPSDevice( - handler: DeviceProtocolHandler, - name: String, -) : INDIDevice(handler, name), GPS { + override val sender: INDIClient, + override val name: String, +) : INDIDevice(), GPS { @Volatile final override var hasGPS = true private set @@ -36,7 +37,7 @@ internal open class GPSDevice( longitude = message["LONG"]!!.value.deg elevation = message["ELEV"]!!.value.m - handler.fireOnEventReceived(GPSCoordinateChanged(this)) + sender.fireOnEventReceived(GPSCoordinateChanged(this)) } } } @@ -48,7 +49,7 @@ internal open class GPSDevice( dateTime = OffsetDateTime.of(utcTime, ZoneOffset.ofTotalSeconds((utcOffset * 60.0).toInt())) - handler.fireOnEventReceived(GPSTimeChanged(this)) + sender.fireOnEventReceived(GPSTimeChanged(this)) } } } @@ -62,6 +63,6 @@ internal open class GPSDevice( override fun toString(): String { return "GPS(hasGPS=$hasGPS, longitude=$longitude, latitude=$latitude," + - " elevation=$elevation, dateTime=$dateTime)" + " elevation=$elevation, dateTime=$dateTime)" } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt index 14e382451..5d3ca68a0 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt @@ -1,5 +1,7 @@ package nebulosa.indi.client.device +import nebulosa.indi.client.INDIClient +import nebulosa.indi.client.device.camera.CameraDevice import nebulosa.indi.device.* import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.dome.Dome @@ -13,26 +15,23 @@ import nebulosa.indi.protocol.Vector import nebulosa.log.loggerFor import java.util.* -internal abstract class INDIDevice( - @JvmField internal val handler: DeviceProtocolHandler, - override val name: String, -) : Device { +internal abstract class INDIDevice : Device { + + abstract override val sender: INDIClient override val properties = linkedMapOf>() override val messages = LinkedList() + override val id = UUID.randomUUID().toString() + @Volatile override var connected = false protected set - override fun sendMessageToServer(message: INDIProtocol) { - handler.sendMessageToServer(message) - } - private fun addMessageAndFireEvent(text: String) { synchronized(messages) { messages.addFirst(text) - handler.fireOnEventReceived(DeviceMessageReceived(this, text)) + sender.fireOnEventReceived(DeviceMessageReceived(this, text)) if (messages.size > 100) { messages.removeLast() @@ -51,23 +50,23 @@ internal abstract class INDIDevice( if (connected) { this.connected = true - handler.fireOnEventReceived(DeviceConnected(this)) + sender.fireOnEventReceived(DeviceConnected(this)) ask() } else if (this.connected) { this.connected = false - handler.fireOnEventReceived(DeviceDisconnected(this)) + sender.fireOnEventReceived(DeviceDisconnected(this)) } } else if (!connected && message.state == PropertyState.ALERT) { - handler.fireOnEventReceived(DeviceConnectionFailed(this)) + sender.fireOnEventReceived(DeviceConnectionFailed(this)) } } } } is DelProperty -> { val property = properties.remove(message.name) ?: return - handler.fireOnEventReceived(DevicePropertyDeleted(property)) + sender.fireOnEventReceived(DevicePropertyDeleted(property)) } is Message -> { addMessageAndFireEvent("[%s]: %s".format(message.timestamp, message.message)) @@ -135,7 +134,7 @@ internal abstract class INDIDevice( properties[property.name] = property - handler.fireOnEventReceived(DevicePropertyChanged(property)) + sender.fireOnEventReceived(DevicePropertyChanged(property)) } is SetVector<*> -> { val property = when (message) { @@ -179,7 +178,7 @@ internal abstract class INDIDevice( else -> return } - handler.fireOnEventReceived(DevicePropertyChanged(property)) + sender.fireOnEventReceived(DevicePropertyChanged(property)) } else -> return } @@ -216,14 +215,24 @@ internal abstract class INDIDevice( sendNewSwitch("CONNECTION", "DISCONNECT" to true) } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CameraDevice) return false + + if (sender != other.sender) return false + if (name != other.name) return false + + return true + } + + override fun hashCode(): Int { + var result = sender.hashCode() + result = 31 * result + name.hashCode() + return result + } + companion object { @JvmStatic private val LOG = loggerFor() - - @JvmStatic - fun Class.create(handler: DeviceProtocolHandler, name: String): T { - return getConstructor(DeviceProtocolHandler::class.java, String::class.java) - .newInstance(handler, name) - } } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/DeviceProtocolHandler.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt similarity index 88% rename from nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/DeviceProtocolHandler.kt rename to nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt index 67ca1450a..064607e97 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/DeviceProtocolHandler.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt @@ -1,12 +1,5 @@ package nebulosa.indi.client.device -import nebulosa.indi.client.device.INDIDevice.Companion.create -import nebulosa.indi.client.device.camera.AsiCamera -import nebulosa.indi.client.device.camera.CameraDevice -import nebulosa.indi.client.device.camera.SVBonyCamera -import nebulosa.indi.client.device.camera.SimCamera -import nebulosa.indi.client.device.mount.IoptronV3Mount -import nebulosa.indi.client.device.mount.MountDevice import nebulosa.indi.device.* import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.CameraAttached @@ -39,7 +32,7 @@ import nebulosa.log.debug import nebulosa.log.loggerFor import java.util.concurrent.LinkedBlockingQueue -abstract class DeviceProtocolHandler : MessageSender, INDIProtocolParser { +abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser { @JvmField protected val cameras = HashMap(2) @JvmField protected val mounts = HashMap(1) @@ -57,6 +50,16 @@ abstract class DeviceProtocolHandler : MessageSender, INDIProtocolParser { val isRunning get() = protocolReader != null + protected abstract fun newCamera(message: INDIProtocol, executable: String): Camera + + protected abstract fun newMount(message: INDIProtocol, executable: String): Mount + + protected abstract fun newFocuser(message: INDIProtocol): Focuser + + protected abstract fun newFilterWheel(message: INDIProtocol): FilterWheel + + protected abstract fun newGPS(message: INDIProtocol): GPS + fun registerDeviceEventHandler(handler: DeviceEventHandler) { handlers.add(handler) } @@ -199,8 +202,7 @@ abstract class DeviceProtocolHandler : MessageSender, INDIProtocolParser { registered = true if (message.device !in cameras) { - val device = CAMERAS[executable]?.create(this, message.device) - ?: CameraDevice(this, message.device) + val device = newCamera(message, executable) cameras[message.device] = device takeMessageFromReorderingQueue(device) LOG.info("camera attached: {}", device.name) @@ -212,8 +214,7 @@ abstract class DeviceProtocolHandler : MessageSender, INDIProtocolParser { registered = true if (message.device !in mounts) { - val device = MOUNTS[executable]?.create(this, message.device) - ?: MountDevice(this, message.device) + val device = newMount(message, executable) mounts[message.device] = device takeMessageFromReorderingQueue(device) LOG.info("mount attached: {}", device.name) @@ -225,7 +226,7 @@ abstract class DeviceProtocolHandler : MessageSender, INDIProtocolParser { registered = true if (message.device !in wheels) { - val device = FilterWheelDevice(this, message.device) + val device = newFilterWheel(message) wheels[message.device] = device takeMessageFromReorderingQueue(device) LOG.info("filter wheel attached: {}", device.name) @@ -237,7 +238,7 @@ abstract class DeviceProtocolHandler : MessageSender, INDIProtocolParser { registered = true if (message.device !in focusers) { - val device = FocuserDevice(this, message.device) + val device = newFocuser(message) focusers[message.device] = device takeMessageFromReorderingQueue(device) LOG.info("focuser attached: {}", device.name) @@ -249,7 +250,7 @@ abstract class DeviceProtocolHandler : MessageSender, INDIProtocolParser { registered = true if (message.device !in gps) { - val device = GPSDevice(this, message.device) + val device = newGPS(message) gps[message.device] = device takeMessageFromReorderingQueue(device) LOG.info("gps attached: {}", device.name) @@ -355,19 +356,6 @@ abstract class DeviceProtocolHandler : MessageSender, INDIProtocolParser { companion object { - @JvmStatic private val LOG = loggerFor() - - @JvmStatic private val CAMERAS = mapOf( - "indi_asi_ccd" to AsiCamera::class.java, - "indi_asi_single_ccd" to AsiCamera::class.java, - "indi_svbony_ccd" to SVBonyCamera::class.java, - "indi_sv305_ccd" to SVBonyCamera::class.java, // legacy name. - "indi_simulator_ccd" to SimCamera::class.java, - "indi_simulator_guide" to SimCamera::class.java, - ) - - @JvmStatic private val MOUNTS = mapOf( - "indi_ioptronv3_telescope" to IoptronV3Mount::class.java, - ) + @JvmStatic private val LOG = loggerFor() } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/AsiCamera.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/AsiCamera.kt index 344df9e8a..0d1820a8f 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/AsiCamera.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/AsiCamera.kt @@ -1,13 +1,13 @@ package nebulosa.indi.client.device.camera -import nebulosa.indi.client.device.DeviceProtocolHandler +import nebulosa.indi.client.INDIClient import nebulosa.indi.protocol.INDIProtocol import nebulosa.indi.protocol.NumberVector internal class AsiCamera( - handler: DeviceProtocolHandler, + provider: INDIClient, name: String, -) : CameraDevice(handler, name) { +) : CameraDevice(provider, name) { override fun handleMessage(message: INDIProtocol) { when (message) { diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt index 1fbd68d10..0ce91df08 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt @@ -1,7 +1,7 @@ package nebulosa.indi.client.device.camera import nebulosa.imaging.algorithms.transformation.CfaPattern -import nebulosa.indi.client.device.DeviceProtocolHandler +import nebulosa.indi.client.INDIClient import nebulosa.indi.client.device.INDIDevice import nebulosa.indi.device.camera.* import nebulosa.indi.device.camera.Camera.Companion.NANO_SECONDS @@ -12,9 +12,9 @@ import nebulosa.log.loggerFor import java.time.Duration internal open class CameraDevice( - handler: DeviceProtocolHandler, - name: String, -) : INDIDevice(handler, name), Camera { + override val sender: INDIClient, + override val name: String, +) : INDIDevice(), Camera { @Volatile final override var exposuring = false private set @@ -123,23 +123,23 @@ internal open class CameraDevice( if (message is DefSwitchVector) { hasCoolerControl = true - handler.fireOnEventReceived(CameraCoolerControlChanged(this)) + sender.fireOnEventReceived(CameraCoolerControlChanged(this)) } cooler = message["COOLER_ON"]?.value ?: false - handler.fireOnEventReceived(CameraCoolerChanged(this)) + sender.fireOnEventReceived(CameraCoolerChanged(this)) } "CCD_CAPTURE_FORMAT" -> { if (message is DefSwitchVector && message.isNotEmpty()) { frameFormats = message.map { it.name } - handler.fireOnEventReceived(CameraFrameFormatsChanged(this)) + sender.fireOnEventReceived(CameraFrameFormatsChanged(this)) } } "CCD_ABORT_EXPOSURE" -> { if (message is DefSwitchVector) { canAbort = message.isNotReadOnly - handler.fireOnEventReceived(CameraCanAbortChanged(this)) + sender.fireOnEventReceived(CameraCanAbortChanged(this)) } } } @@ -150,7 +150,7 @@ internal open class CameraDevice( cfaOffsetX = message["CFA_OFFSET_X"]!!.value.toInt() cfaOffsetY = message["CFA_OFFSET_Y"]!!.value.toInt() cfaType = CfaPattern.valueOf(message["CFA_TYPE"]!!.value) - handler.fireOnEventReceived(CameraCfaChanged(this)) + sender.fireOnEventReceived(CameraCfaChanged(this)) } } } @@ -160,7 +160,7 @@ internal open class CameraDevice( pixelSizeX = message["CCD_PIXEL_SIZE_X"]?.value ?: 0.0 pixelSizeY = message["CCD_PIXEL_SIZE_Y"]?.value ?: 0.0 - handler.fireOnEventReceived(CameraPixelSizeChanged(this)) + sender.fireOnEventReceived(CameraPixelSizeChanged(this)) } "CCD_EXPOSURE" -> { val element = message["CCD_EXPOSURE_VALUE"]!! @@ -168,7 +168,7 @@ internal open class CameraDevice( if (element is DefNumber) { exposureMin = Duration.ofNanos((element.min * NANO_SECONDS).toLong()) exposureMax = Duration.ofNanos((element.max * NANO_SECONDS).toLong()) - handler.fireOnEventReceived(CameraExposureMinMaxChanged(this)) + sender.fireOnEventReceived(CameraExposureMinMaxChanged(this)) } val prevExposureState = exposureState @@ -177,53 +177,53 @@ internal open class CameraDevice( if (exposureState == PropertyState.BUSY || exposureState == PropertyState.OK) { exposureTime = Duration.ofNanos((element.value * NANO_SECONDS).toLong()) - handler.fireOnEventReceived(CameraExposureProgressChanged(this)) + sender.fireOnEventReceived(CameraExposureProgressChanged(this)) } val prevIsExposuring = exposuring exposuring = exposureState == PropertyState.BUSY if (prevIsExposuring != exposuring) { - handler.fireOnEventReceived(CameraExposuringChanged(this)) + sender.fireOnEventReceived(CameraExposuringChanged(this)) } if (exposureState == PropertyState.IDLE && (prevExposureState == PropertyState.BUSY || exposuring)) { - handler.fireOnEventReceived(CameraExposureAborted(this)) + sender.fireOnEventReceived(CameraExposureAborted(this)) } else if (exposureState == PropertyState.OK && prevExposureState == PropertyState.BUSY) { - handler.fireOnEventReceived(CameraExposureFinished(this)) + sender.fireOnEventReceived(CameraExposureFinished(this)) } else if (exposureState == PropertyState.ALERT && prevExposureState != PropertyState.ALERT) { - handler.fireOnEventReceived(CameraExposureFailed(this)) + sender.fireOnEventReceived(CameraExposureFailed(this)) } if (prevExposureState != exposureState) { - handler.fireOnEventReceived(CameraExposureStateChanged(this, prevExposureState)) + sender.fireOnEventReceived(CameraExposureStateChanged(this, prevExposureState)) } } "CCD_COOLER_POWER" -> { coolerPower = message.first().value - handler.fireOnEventReceived(CameraCoolerPowerChanged(this)) + sender.fireOnEventReceived(CameraCoolerPowerChanged(this)) } "CCD_TEMPERATURE" -> { if (message is DefNumberVector) { hasCooler = true canSetTemperature = message.isNotReadOnly - handler.fireOnEventReceived(CameraHasCoolerChanged(this)) - handler.fireOnEventReceived(CameraCanSetTemperatureChanged(this)) + sender.fireOnEventReceived(CameraHasCoolerChanged(this)) + sender.fireOnEventReceived(CameraCanSetTemperatureChanged(this)) if (!hasThermometer) { hasThermometer = true - handler.registerThermometer(this) + sender.registerThermometer(this) } } temperature = message["CCD_TEMPERATURE_VALUE"]!!.value - handler.fireOnEventReceived(CameraTemperatureChanged(this)) + sender.fireOnEventReceived(CameraTemperatureChanged(this)) } "CCD_FRAME" -> { if (message is DefNumberVector) { canSubFrame = message.isNotReadOnly - handler.fireOnEventReceived(CameraCanSubFrameChanged(this)) + sender.fireOnEventReceived(CameraCanSubFrameChanged(this)) val minX = message["X"]!!.min.toInt() val maxX = message["X"]!!.max.toInt() @@ -254,7 +254,7 @@ internal open class CameraDevice( this.width = width this.height = height - handler.fireOnEventReceived(CameraFrameChanged(this)) + sender.fireOnEventReceived(CameraFrameChanged(this)) } "CCD_BINNING" -> { if (message is DefNumberVector) { @@ -262,20 +262,20 @@ internal open class CameraDevice( maxBinX = message["HOR_BIN"]!!.max.toInt() maxBinY = message["VER_BIN"]!!.max.toInt() - handler.fireOnEventReceived(CameraCanBinChanged(this)) + sender.fireOnEventReceived(CameraCanBinChanged(this)) } binX = message["HOR_BIN"]!!.value.toInt() binY = message["VER_BIN"]!!.value.toInt() - handler.fireOnEventReceived(CameraBinChanged(this)) + sender.fireOnEventReceived(CameraBinChanged(this)) } "TELESCOPE_TIMED_GUIDE_NS", "TELESCOPE_TIMED_GUIDE_WE" -> { if (!canPulseGuide && message is DefNumberVector) { canPulseGuide = true - handler.registerGuideOutput(this) + sender.registerGuideOutput(this) LOG.info("guide output attached: {}", name) } else { @@ -283,7 +283,7 @@ internal open class CameraDevice( pulseGuiding = message.isBusy if (pulseGuiding != prevIsPulseGuiding) { - handler.fireOnEventReceived(GuideOutputPulsingChanged(this)) + sender.fireOnEventReceived(GuideOutputPulsingChanged(this)) } } } @@ -295,7 +295,7 @@ internal open class CameraDevice( val ccd1 = message["CCD1"]!! val fits = Base64InputStream(ccd1.value) val compressed = COMPRESSION_FORMATS.any { ccd1.format.endsWith(it, true) } - handler.fireOnEventReceived(CameraFrameCaptured(this, fits, null, compressed)) + sender.fireOnEventReceived(CameraFrameCaptured(this, fits, null, compressed)) } "CCD2" -> { // TODO: Handle Guider Head frame. @@ -386,13 +386,13 @@ internal open class CameraDevice( override fun close() { if (hasThermometer) { - handler.unregisterThermometer(this) + sender.unregisterThermometer(this) hasThermometer = false LOG.info("thermometer detached: {}", name) } if (canPulseGuide) { - handler.unregisterGuideOutput(this) + sender.unregisterGuideOutput(this) canPulseGuide = false LOG.info("guide output detached: {}", name) } @@ -403,12 +403,12 @@ internal open class CameraDevice( gainMin = element.min.toInt() gainMax = element.max.toInt() - handler.fireOnEventReceived(CameraGainMinMaxChanged(this)) + sender.fireOnEventReceived(CameraGainMinMaxChanged(this)) } gain = element.value.toInt() - handler.fireOnEventReceived(CameraGainChanged(this)) + sender.fireOnEventReceived(CameraGainChanged(this)) } protected fun processOffset(message: NumberVector<*>, element: NumberElement) { @@ -416,12 +416,12 @@ internal open class CameraDevice( offsetMin = element.min.toInt() offsetMax = element.max.toInt() - handler.fireOnEventReceived(CameraOffsetMinMaxChanged(this)) + sender.fireOnEventReceived(CameraOffsetMinMaxChanged(this)) } offset = element.value.toInt() - handler.fireOnEventReceived(CameraOffsetChanged(this)) + sender.fireOnEventReceived(CameraOffsetChanged(this)) } override fun toString() = "Camera(name=$name, connected=$connected, exposuring=$exposuring," + diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/SVBonyCamera.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/SVBonyCamera.kt index 71df208b5..172e4359b 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/SVBonyCamera.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/SVBonyCamera.kt @@ -1,13 +1,13 @@ package nebulosa.indi.client.device.camera -import nebulosa.indi.client.device.DeviceProtocolHandler +import nebulosa.indi.client.INDIClient import nebulosa.indi.protocol.INDIProtocol import nebulosa.indi.protocol.NumberVector internal class SVBonyCamera( - handler: DeviceProtocolHandler, + provider: INDIClient, name: String, -) : CameraDevice(handler, name) { +) : CameraDevice(provider, name) { @Volatile private var legacyProperties = false diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/SimCamera.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/SimCamera.kt index b505b8560..b2298bf88 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/SimCamera.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/SimCamera.kt @@ -1,13 +1,13 @@ package nebulosa.indi.client.device.camera -import nebulosa.indi.client.device.DeviceProtocolHandler +import nebulosa.indi.client.INDIClient import nebulosa.indi.protocol.INDIProtocol import nebulosa.indi.protocol.NumberVector internal class SimCamera( - handler: DeviceProtocolHandler, + provider: INDIClient, name: String, -) : CameraDevice(handler, name) { +) : CameraDevice(provider, name) { override fun handleMessage(message: INDIProtocol) { when (message) { diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/IoptronV3Mount.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/IoptronV3Mount.kt index 57d90e4b5..e7f65f1cb 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/IoptronV3Mount.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/IoptronV3Mount.kt @@ -1,14 +1,14 @@ package nebulosa.indi.client.device.mount -import nebulosa.indi.client.device.DeviceProtocolHandler +import nebulosa.indi.client.INDIClient import nebulosa.indi.device.mount.MountCanHomeChanged import nebulosa.indi.protocol.INDIProtocol import nebulosa.indi.protocol.SwitchVector internal class IoptronV3Mount( - handler: DeviceProtocolHandler, + provider: INDIClient, name: String, -) : MountDevice(handler, name) { +) : MountDevice(provider, name) { override fun handleMessage(message: INDIProtocol) { when (message) { @@ -16,7 +16,7 @@ internal class IoptronV3Mount( when (message.name) { "HOME" -> { canHome = true - handler.fireOnEventReceived(MountCanHomeChanged(this)) + sender.fireOnEventReceived(MountCanHomeChanged(this)) } } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt index 442adcde0..dc6deaf3a 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt @@ -1,6 +1,6 @@ package nebulosa.indi.client.device.mount -import nebulosa.indi.client.device.DeviceProtocolHandler +import nebulosa.indi.client.INDIClient import nebulosa.indi.client.device.INDIDevice import nebulosa.indi.device.firstOnSwitch import nebulosa.indi.device.firstOnSwitchOrNull @@ -16,9 +16,9 @@ import java.time.OffsetDateTime import java.time.ZoneOffset internal open class MountDevice( - handler: DeviceProtocolHandler, - name: String, -) : INDIDevice(handler, name), Mount { + override val sender: INDIClient, + override val name: String, +) : INDIDevice(), Mount { @Volatile final override var slewing = false private set @@ -83,36 +83,36 @@ internal open class MountDevice( if (message is DefSwitchVector) { slewRates = message.map { SlewRate(it.name, it.label) } - handler.fireOnEventReceived(MountSlewRatesChanged(this)) + sender.fireOnEventReceived(MountSlewRatesChanged(this)) } val name = message.firstOnSwitch().name if (slewRate?.name != name) { slewRate = slewRates.firstOrNull { it.name == name } - handler.fireOnEventReceived(MountSlewRateChanged(this)) + sender.fireOnEventReceived(MountSlewRateChanged(this)) } } // "MOUNT_TYPE" -> { // mountType = MountType.valueOf(message.firstOnSwitch().name) // - // handler.fireOnEventReceived(MountTypeChanged(this)) + // provider.fireOnEventReceived(MountTypeChanged(this)) // } "TELESCOPE_TRACK_MODE" -> { if (message is DefSwitchVector) { trackModes = message.map { TrackMode.valueOf(it.name.replace("TRACK_", "")) } - handler.fireOnEventReceived(MountTrackModesChanged(this)) + sender.fireOnEventReceived(MountTrackModesChanged(this)) } trackMode = TrackMode.valueOf(message.firstOnSwitch().name.replace("TRACK_", "")) - handler.fireOnEventReceived(MountTrackModeChanged(this)) + sender.fireOnEventReceived(MountTrackModeChanged(this)) } "TELESCOPE_TRACK_STATE" -> { tracking = message.firstOnSwitch().name == "TRACK_ON" - handler.fireOnEventReceived(MountTrackingChanged(this)) + sender.fireOnEventReceived(MountTrackingChanged(this)) } "TELESCOPE_PIER_SIDE" -> { val side = message.firstOnSwitchOrNull() @@ -121,31 +121,31 @@ internal open class MountDevice( else if (side.name == "PIER_WEST") PierSide.WEST else PierSide.EAST - handler.fireOnEventReceived(MountPierSideChanged(this)) + sender.fireOnEventReceived(MountPierSideChanged(this)) } "TELESCOPE_PARK" -> { if (message is DefSwitchVector) { canPark = message.isNotReadOnly - handler.fireOnEventReceived(MountCanParkChanged(this)) + sender.fireOnEventReceived(MountCanParkChanged(this)) } parking = message.isBusy parked = message.firstOnSwitchOrNull()?.name == "PARK" - handler.fireOnEventReceived(MountParkChanged(this)) + sender.fireOnEventReceived(MountParkChanged(this)) } "TELESCOPE_ABORT_MOTION" -> { canAbort = true - handler.fireOnEventReceived(MountCanAbortChanged(this)) + sender.fireOnEventReceived(MountCanAbortChanged(this)) } "ON_COORD_SET" -> { canSync = message.any { it.name == "SYNC" } canGoTo = message.any { it.name == "TRACK" } - handler.fireOnEventReceived(MountCanSyncChanged(this)) - handler.fireOnEventReceived(MountCanGoToChanged(this)) + sender.fireOnEventReceived(MountCanSyncChanged(this)) + sender.fireOnEventReceived(MountCanGoToChanged(this)) } } } @@ -155,31 +155,31 @@ internal open class MountDevice( // guideRateWE = message["GUIDE_RATE_WE"]!!.value // guideRateNS = message["GUIDE_RATE_NS"]!!.value // - // handler.fireOnEventReceived(MountGuideRateChanged(this)) + // provider.fireOnEventReceived(MountGuideRateChanged(this)) // } "EQUATORIAL_EOD_COORD" -> { if (message.state == PropertyState.ALERT) { - handler.fireOnEventReceived(MountSlewFailed(this)) + sender.fireOnEventReceived(MountSlewFailed(this)) } val prevIsIslewing = slewing slewing = message.isBusy if (slewing != prevIsIslewing) { - handler.fireOnEventReceived(MountSlewingChanged(this)) + sender.fireOnEventReceived(MountSlewingChanged(this)) } rightAscension = message["RA"]!!.value.hours declination = message["DEC"]!!.value.deg - handler.fireOnEventReceived(MountEquatorialCoordinatesChanged(this)) + sender.fireOnEventReceived(MountEquatorialCoordinatesChanged(this)) } "TELESCOPE_TIMED_GUIDE_NS", "TELESCOPE_TIMED_GUIDE_WE" -> { if (!canPulseGuide && message is DefNumberVector) { canPulseGuide = true - handler.registerGuideOutput(this) + sender.registerGuideOutput(this) LOG.info("guide output attached: {}", name) } @@ -189,7 +189,7 @@ internal open class MountDevice( pulseGuiding = message.isBusy if (pulseGuiding != prevIsPulseGuiding) { - handler.fireOnEventReceived(GuideOutputPulsingChanged(this)) + sender.fireOnEventReceived(GuideOutputPulsingChanged(this)) } } } @@ -198,7 +198,7 @@ internal open class MountDevice( longitude = message["LONG"]!!.value.deg elevation = message["ELEV"]!!.value.m - handler.fireOnEventReceived(MountGeographicCoordinateChanged(this)) + sender.fireOnEventReceived(MountGeographicCoordinateChanged(this)) } } } @@ -210,7 +210,7 @@ internal open class MountDevice( dateTime = OffsetDateTime.of(utcTime, ZoneOffset.ofTotalSeconds((utcOffset * 3600.0).toInt())) - handler.fireOnEventReceived(MountTimeChanged(this)) + sender.fireOnEventReceived(MountTimeChanged(this)) } } } @@ -341,13 +341,13 @@ internal open class MountDevice( override fun close() { if (canPulseGuide) { canPulseGuide = false - handler.unregisterGuideOutput(this) + sender.unregisterGuideOutput(this) LOG.info("guide output detached: {}", name) } if (hasGPS) { hasGPS = false - handler.unregisterGPS(this) + sender.unregisterGPS(this) LOG.info("GPS detached: {}", name) } } diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/Device.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/Device.kt index 59c0a6910..2e575b3cf 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/Device.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/Device.kt @@ -6,6 +6,10 @@ import java.io.Closeable interface Device : INDIProtocolHandler, Closeable { + val sender: MessageSender + + val id: String + val name: String val connected: Boolean @@ -18,7 +22,9 @@ interface Device : INDIProtocolHandler, Closeable { fun disconnect() - fun sendMessageToServer(message: INDIProtocol) + fun sendMessageToServer(message: INDIProtocol) { + sender.sendMessageToServer(message) + } fun ask() { sendMessageToServer(GetProperties().also { it.device = name }) diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/INDIDeviceProvider.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/INDIDeviceProvider.kt index c7e1310dc..2f6083188 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/INDIDeviceProvider.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/INDIDeviceProvider.kt @@ -9,7 +9,7 @@ import nebulosa.indi.device.mount.Mount import nebulosa.indi.device.thermometer.Thermometer import java.io.Closeable -interface INDIDeviceProvider : Closeable { +interface INDIDeviceProvider : MessageSender, Closeable { fun registerDeviceEventHandler(handler: DeviceEventHandler) diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/MessageSender.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/MessageSender.kt index 6cdfc6b31..ba4326939 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/MessageSender.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/MessageSender.kt @@ -4,5 +4,7 @@ import nebulosa.indi.protocol.INDIProtocol interface MessageSender { + val id: String + fun sendMessageToServer(message: INDIProtocol) } From 48be77ac6c50996a5a409d5be358046c60d764c0 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sun, 18 Feb 2024 17:41:32 -0300 Subject: [PATCH 37/87] [api]: Add support to ASCOM Alpaca API --- .../nebulosa/api/wheels/WheelService.kt | 4 +- .../nebulosa/alpaca/api/AlignmentMode.kt | 10 + ...ayResponse.kt => AlignmentModeResponse.kt} | 7 +- .../alpaca/api/AlpacaCameraService.kt | 18 +- .../alpaca/api/AlpacaFilterWheelService.kt | 26 ++ .../alpaca/api/AlpacaGuideOutputService.kt | 12 + .../nebulosa/alpaca/api/AlpacaService.kt | 2 + .../alpaca/api/AlpacaTelescopeService.kt | 290 +++++++++++++++++- .../nebulosa/alpaca/api/ArrayResponse.kt | 18 ++ .../kotlin/nebulosa/alpaca/api/AxisRate.kt | 8 + .../kotlin/nebulosa/alpaca/api/AxisType.kt | 10 + .../nebulosa/alpaca/api/DateTimeResponse.kt | 12 + .../kotlin/nebulosa/alpaca/api/DriveRate.kt | 11 + .../nebulosa/alpaca/api/DriveRateResponse.kt | 11 + .../alpaca/api/EquatorialCoordinateType.kt | 12 + .../api/EquatorialCoordinateTypeResponse.kt | 11 + .../nebulosa/alpaca/api/IntArrayResponse.kt | 18 ++ .../kotlin/nebulosa/alpaca/api/PierSide.kt | 9 + .../nebulosa/alpaca/api/PierSideResponse.kt | 11 + .../alpaca/indi/client/AlpacaClient.kt | 84 +++-- .../indi/{devices => device}/ASCOMDevice.kt | 2 +- .../alpaca/indi/device/ASCOMFilterWheel.kt | 64 ++++ .../cameras/ASCOMCamera.kt | 16 +- .../cameras/ImageArrayElementType.kt | 2 +- .../alpaca/indi/device/mounts/ASCOMMount.kt | 204 ++++++++++++ .../kotlin/nebulosa/indi/client/INDIClient.kt | 12 +- .../indi/client/device/FilterWheelDevice.kt | 2 +- .../nebulosa/indi/client/device/INDIDevice.kt | 2 +- .../device/{camera => cameras}/AsiCamera.kt | 2 +- .../{camera => cameras}/CameraDevice.kt | 2 +- .../{camera => cameras}/SVBonyCamera.kt | 2 +- .../device/{camera => cameras}/SimCamera.kt | 2 +- .../{mount => mounts}/IoptronV3Mount.kt | 2 +- .../device/{mount => mounts}/MountDevice.kt | 2 +- .../indi/device/filterwheel/FilterWheel.kt | 2 +- 35 files changed, 842 insertions(+), 60 deletions(-) create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlignmentMode.kt rename nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/{StringArrayResponse.kt => AlignmentModeResponse.kt} (69%) create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFilterWheelService.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaGuideOutputService.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ArrayResponse.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AxisRate.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AxisType.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DateTimeResponse.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DriveRate.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DriveRateResponse.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/EquatorialCoordinateType.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/EquatorialCoordinateTypeResponse.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/IntArrayResponse.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/PierSide.kt create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/PierSideResponse.kt rename nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/{devices => device}/ASCOMDevice.kt (99%) create mode 100644 nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMFilterWheel.kt rename nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/{devices => device}/cameras/ASCOMCamera.kt (98%) rename nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/{devices => device}/cameras/ImageArrayElementType.kt (91%) create mode 100644 nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt rename nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/{camera => cameras}/AsiCamera.kt (96%) rename nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/{camera => cameras}/CameraDevice.kt (99%) rename nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/{camera => cameras}/SVBonyCamera.kt (97%) rename nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/{camera => cameras}/SimCamera.kt (95%) rename nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/{mount => mounts}/IoptronV3Mount.kt (94%) rename nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/{mount => mounts}/MountDevice.kt (99%) diff --git a/api/src/main/kotlin/nebulosa/api/wheels/WheelService.kt b/api/src/main/kotlin/nebulosa/api/wheels/WheelService.kt index 7a54d2f62..b82576114 100644 --- a/api/src/main/kotlin/nebulosa/api/wheels/WheelService.kt +++ b/api/src/main/kotlin/nebulosa/api/wheels/WheelService.kt @@ -18,7 +18,7 @@ class WheelService { wheel.moveTo(steps) } - fun sync(wheel: FilterWheel, filterNames: List) { - wheel.syncNames(filterNames) + fun sync(wheel: FilterWheel, names: List) { + wheel.names(names) } } diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlignmentMode.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlignmentMode.kt new file mode 100644 index 000000000..11aad11ee --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlignmentMode.kt @@ -0,0 +1,10 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonFormat + +@JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) +enum class AlignmentMode { + ALT_AZ, + EQUATORIAL, + GERMAN_EQUATORIAL, +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/StringArrayResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlignmentModeResponse.kt similarity index 69% rename from nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/StringArrayResponse.kt rename to nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlignmentModeResponse.kt index c0183d7ce..208965baa 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/StringArrayResponse.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlignmentModeResponse.kt @@ -2,11 +2,10 @@ package nebulosa.alpaca.api import com.fasterxml.jackson.annotation.JsonProperty -@Suppress("ArrayInDataClass") -data class StringArrayResponse( +data class AlignmentModeResponse( @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", - @field:JsonProperty("Value") override val value: Array = emptyArray(), -) : AlpacaResponse> + @field:JsonProperty("Value") override val value: AlignmentMode = AlignmentMode.ALT_AZ, +) : AlpacaResponse diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt index 11dd20f1c..2a2eff8f2 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt @@ -4,7 +4,7 @@ import okhttp3.ResponseBody import retrofit2.Call import retrofit2.http.* -interface AlpacaCameraService : AlpacaDeviceService { +interface AlpacaCameraService : AlpacaGuideOutputService { @GET("api/v1/camera/{id}/connected") override fun isConnected(@Path("id") id: Int): Call @@ -55,7 +55,7 @@ interface AlpacaCameraService : AlpacaDeviceService { fun canCoolerPower(@Path("id") id: Int): Call @GET("api/v1/camera/{id}/canpulseguide") - fun canPulseGuide(@Path("id") id: Int): Call + override fun canPulseGuide(@Path("id") id: Int): Call @GET("api/v1/camera/{id}/cansetccdtemperature") fun canSetCCDTemperature(@Path("id") id: Int): Call @@ -112,7 +112,7 @@ interface AlpacaCameraService : AlpacaDeviceService { fun gainMin(@Path("id") id: Int): Call @GET("api/v1/camera/{id}/gains") - fun gains(@Path("id") id: Int): Call + fun gains(@Path("id") id: Int): Call> @GET("api/v1/camera/{id}/hasshutter") fun hasShutter(@Path("id") id: Int): Call @@ -124,7 +124,7 @@ interface AlpacaCameraService : AlpacaDeviceService { fun isImageReady(@Path("id") id: Int): Call @GET("api/v1/camera/{id}/ispulseguiding") - fun isPulseGuiding(@Path("id") id: Int): Call + override fun isPulseGuiding(@Path("id") id: Int): Call @GET("api/v1/camera/{id}/lastexposureduration") fun lastExposureDuration(@Path("id") id: Int): Call @@ -169,7 +169,7 @@ interface AlpacaCameraService : AlpacaDeviceService { fun offsetMin(@Path("id") id: Int): Call @GET("api/v1/camera/{id}/offsets") - fun offsets(@Path("id") id: Int): Call + fun offsets(@Path("id") id: Int): Call> @GET("api/v1/camera/{id}/percentcompleted") fun percentCompleted(@Path("id") id: Int): Call @@ -188,7 +188,7 @@ interface AlpacaCameraService : AlpacaDeviceService { fun readoutMode(@Path("id") id: Int, @Field("ReadoutMode") value: Int): Call @GET("api/v1/camera/{id}/readoutmodes") - fun readoutModes(@Path("id") id: Int): Call + fun readoutModes(@Path("id") id: Int): Call> @GET("api/v1/camera/{id}/sensorname") fun sensorName(@Path("id") id: Int): Call @@ -229,7 +229,11 @@ interface AlpacaCameraService : AlpacaDeviceService { @FormUrlEncoded @PUT("api/v1/camera/{id}/pulseguide") - fun pulseGuide(@Path("id") id: Int, @Field("Direction") direction: PulseGuideDirection, @Field("Duration") durationMs: Long): Call + override fun pulseGuide( + @Path("id") id: Int, + @Field("Direction") direction: PulseGuideDirection, + @Field("Duration") durationMs: Long + ): Call @FormUrlEncoded @PUT("api/v1/camera/{id}/startexposure") diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFilterWheelService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFilterWheelService.kt new file mode 100644 index 000000000..3d5922a55 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFilterWheelService.kt @@ -0,0 +1,26 @@ +package nebulosa.alpaca.api + +import retrofit2.Call +import retrofit2.http.* + +interface AlpacaFilterWheelService : AlpacaDeviceService { + + @GET("api/v1/filterwheel/{id}/connected") + override fun isConnected(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/filterwheel/{id}/connected") + override fun connect(@Path("id") id: Int, @Field("Connected") connected: Boolean): Call + + @GET("api/v1/filterwheel/{id}/focusoffsets") + fun focusOffsets(@Path("id") id: Int): Call + + @GET("api/v1/filterwheel/{id}/names") + fun names(@Path("id") id: Int): Call> + + @GET("api/v1/filterwheel/{id}/position") + fun position(@Path("id") id: Int): Call + + @GET("api/v1/filterwheel/{id}/alignmentmode") + fun position(@Path("id") id: Int, @Field("Position") position: Int): Call +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaGuideOutputService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaGuideOutputService.kt new file mode 100644 index 000000000..6d37e82a6 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaGuideOutputService.kt @@ -0,0 +1,12 @@ +package nebulosa.alpaca.api + +import retrofit2.Call + +interface AlpacaGuideOutputService : AlpacaDeviceService { + + fun canPulseGuide(id: Int): Call + + fun isPulseGuiding(id: Int): Call + + fun pulseGuide(id: Int, direction: PulseGuideDirection, durationMs: Long): Call +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt index 9d5b2aeab..e4869ffa6 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt @@ -20,4 +20,6 @@ class AlpacaService( val camera by lazy { retrofit.create() } val telescope by lazy { retrofit.create() } + + val filterWheel by lazy { retrofit.create() } } diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt index 2a786737a..810e20977 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt @@ -2,8 +2,9 @@ package nebulosa.alpaca.api import retrofit2.Call import retrofit2.http.* +import java.time.Instant -interface AlpacaTelescopeService : AlpacaDeviceService { +interface AlpacaTelescopeService : AlpacaGuideOutputService { @GET("api/v1/telescope/{id}/connected") override fun isConnected(@Path("id") id: Int): Call @@ -11,4 +12,291 @@ interface AlpacaTelescopeService : AlpacaDeviceService { @FormUrlEncoded @PUT("api/v1/telescope/{id}/connected") override fun connect(@Path("id") id: Int, @Field("Connected") connected: Boolean): Call + + @GET("api/v1/telescope/{id}/alignmentmode") + fun alignmentMode(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/altitude") + fun altitude(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/aperturearea") + fun apertureArea(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/aperturediameter") + fun apertureDiameter(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/athome") + fun isAtHome(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/atpark") + fun isAtPark(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/azimuth") + fun azimuth(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/canfindhome") + fun canFindHome(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/canpark") + fun canPark(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/canpulseguide") + override fun canPulseGuide(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/cansetdeclinationrate") + fun canSetDeclinationRate(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/cansetguiderates") + fun canSetGuideRates(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/cansetpark") + fun canSetPark(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/cansetpierside") + fun canSetPierSide(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/cansetrightascensionrate") + fun canSetRightAscensionRate(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/cansettracking") + fun canSetTracking(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/canslew") + fun canSlew(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/canslewaltaz") + fun canSlewAltAz(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/canslewaltazasync") + fun canSlewAltAzAsync(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/canslewasync") + fun canSlewAsync(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/cansync") + fun canSync(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/cansyncaltaz") + fun canSyncAltAz(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/canunpark") + fun canUnpark(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/declination") + fun declination(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/declinationrate") + fun declinationRate(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/declinationrate") + fun declinationRate(@Path("id") id: Int, @Field("DeclinationRate") rate: Double): Call + + @GET("api/v1/telescope/{id}/doesrefraction") + fun doesRefraction(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/doesrefraction") + fun doesRefraction(@Path("id") id: Int, @Field("DoesRefraction") doesRefraction: Boolean): Call + + @GET("api/v1/telescope/{id}/equatorialsystem") + fun equatorialSystem(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/focallength") + fun focalLength(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/guideratedeclination") + fun guideRateDeclination(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/guideratedeclination") + fun guideRateDeclination(@Path("id") id: Int, @Field("GuideRateDeclination") rate: Double): Call + + @GET("api/v1/telescope/{id}/guideraterightascension") + fun guideRateRightAscension(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/guideraterightascension") + fun guideRateRightAscension(@Path("id") id: Int, @Field("GuideRateRightAscension") rate: Double): Call + + @GET("api/v1/telescope/{id}/ispulseguiding") + override fun isPulseGuiding(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/rightascension") + fun rightAscension(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/rightascensionrate") + fun rightAscensionRate(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/rightascensionrate") + fun rightAscensionRate(@Path("id") id: Int, @Field("RightAscensionRate") rate: Double): Call + + @GET("api/v1/telescope/{id}/sideofpier") + fun sideOfPier(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/sideofpier") + fun sideofPier(@Path("id") id: Int, @Field("SideOfPier") side: PierSide): Call + + @GET("api/v1/telescope/{id}/siderealtime") + fun siderealTime(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/siteelevation") + fun siteElevation(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/siteelevation") + fun siteElevation(@Path("id") id: Int, @Field("SiteElevation") elevation: Double): Call + + @GET("api/v1/telescope/{id}/sitelatitude") + fun siteLatitude(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/sitelatitude") + fun siteLatitude(@Path("id") id: Int, @Field("SiteLatitude") latitude: Double): Call + + @GET("api/v1/telescope/{id}/sitelongitude") + fun siteLongitude(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/sitelongitude") + fun siteLongitude(@Path("id") id: Int, @Field("SiteLongitude") longitude: Double): Call + + @GET("api/v1/telescope/{id}/slewing") + fun isSlewing(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/slewsettletime") + fun slewSettleTime(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/slewsettletime") + fun slewSettleTime(@Path("id") id: Int, @Field("SlewSettleTime") settleTime: Int): Call + + @GET("api/v1/telescope/{id}/targetdeclination") + fun targetDeclination(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/targetdeclination") + fun targetDeclination(@Path("id") id: Int, @Field("TargetDeclination") declination: Double): Call + + @GET("api/v1/telescope/{id}/targetrightascension") + fun targetRightAscension(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/targetrightascension") + fun targetRightAscension(@Path("id") id: Int, @Field("TargetRightAscension") rightAscension: Double): Call + + @GET("api/v1/telescope/{id}/tracking") + fun isTracking(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/tracking") + fun tracking(@Path("id") id: Int, @Field("Tracking") tracking: Boolean): Call + + @GET("api/v1/telescope/{id}/trackingrate") + fun trackingRate(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/trackingrate") + fun trackingRate(@Path("id") id: Int, @Field("TrackingRate") rate: DriveRate): Call + + @GET("api/v1/telescope/{id}/trackingrates") + fun trackingRates(@Path("id") id: Int): Call> + + @GET("api/v1/telescope/{id}/utcdate") + fun utcDate(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/utcDate") + fun utcDate(@Path("id") id: Int, @Field("UTCDate") date: Instant): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/abortslew") + fun abortSlew(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/axisrates") + fun axisRates(@Path("id") id: Int): Call> + + @GET("api/v1/telescope/{id}/canmoveaxis") + fun canMoveAxis(@Path("id") id: Int): Call + + @GET("api/v1/telescope/{id}/destinationsideofpier") + fun destinationSideOfPier(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/findhome") + fun findHome(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/moveaxis") + fun moveAxis(@Path("id") id: Int, @Field("Axis") axis: AxisType, @Field("Rate") rate: Double): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/park") + fun park(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/pulseguide") + override fun pulseGuide( + @Path("id") id: Int, + @Field("Direction") direction: PulseGuideDirection, + @Field("Duration") durationMs: Long + ): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/setpark") + fun setPark(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/slewtoaltaz") + fun slewToAltAz(@Path("id") id: Int, @Field("Azimuth") azimuth: Double, @Field("Altitude") altitude: Double): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/slewtoaltazasync") + fun slewtoAltAzAsync(@Path("id") id: Int, @Field("Azimuth") azimuth: Double, @Field("Altitude") altitude: Double): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/slewtocoordinates") + fun slewToCoordinates( + @Path("id") id: Int, + @Field("RightAscension") rightAscension: Double, + @Field("Declination") declination: Double + ): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/slewtocoordinatesasync") + fun slewToCoordinatesAsync( + @Path("id") id: Int, + @Field("RightAscension") rightAscension: Double, + @Field("Declination") declination: Double + ): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/slewtotarget") + fun slewToTarget(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/slewtotargetasync") + fun slewToTargetAsync(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/synctoaltaz") + fun syncToAltAz(@Path("id") id: Int, @Field("Azimuth") azimuth: Double, @Field("Altitude") altitude: Double): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/synctocoordinates") + fun syncToCoordinates( + @Path("id") id: Int, + @Field("RightAscension") rightAscension: Double, + @Field("Declination") declination: Double + ): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/synctotarget") + fun syncToTarget(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/telescope/{id}/unpark") + fun unpark(@Path("id") id: Int): Call } diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ArrayResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ArrayResponse.kt new file mode 100644 index 000000000..90f3b2827 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/ArrayResponse.kt @@ -0,0 +1,18 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +@Suppress("ArrayInDataClass", "UNCHECKED_CAST") +data class ArrayResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", + @field:JsonProperty("Value") override val value: Array = EMPTY_ARRAY as Array, +) : AlpacaResponse> { + + companion object { + + @JvmStatic internal val EMPTY_ARRAY = arrayOfNulls(0) + } +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AxisRate.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AxisRate.kt new file mode 100644 index 000000000..2fafac8d6 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AxisRate.kt @@ -0,0 +1,8 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +data class AxisRate( + @field:JsonProperty("Maximum") val maximum: Double, + @field:JsonProperty("Minimum") val minimum: Double, +) diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AxisType.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AxisType.kt new file mode 100644 index 000000000..8c1819b93 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AxisType.kt @@ -0,0 +1,10 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonFormat + +@JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) +enum class AxisType { + PRIMARY, + SECONDARY, + TERTIARY, +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DateTimeResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DateTimeResponse.kt new file mode 100644 index 000000000..a43a09382 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DateTimeResponse.kt @@ -0,0 +1,12 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.Instant + +data class DateTimeResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", + @field:JsonProperty("Value") override val value: Instant = Instant.now(), +) : AlpacaResponse diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DriveRate.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DriveRate.kt new file mode 100644 index 000000000..99aec1c84 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DriveRate.kt @@ -0,0 +1,11 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonFormat + +@JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) +enum class DriveRate { + SIDEREAL, + LUNAR, + SOLAR, + KING, +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DriveRateResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DriveRateResponse.kt new file mode 100644 index 000000000..9cdbc1520 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DriveRateResponse.kt @@ -0,0 +1,11 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +data class DriveRateResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", + @field:JsonProperty("Value") override val value: DriveRate = DriveRate.SIDEREAL, +) : AlpacaResponse diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/EquatorialCoordinateType.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/EquatorialCoordinateType.kt new file mode 100644 index 000000000..b0bca8f98 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/EquatorialCoordinateType.kt @@ -0,0 +1,12 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonFormat + +@JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) +enum class EquatorialCoordinateType { + OTHER, + TOPOCENTRIC, + J2000, + J2050, + B1950, +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/EquatorialCoordinateTypeResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/EquatorialCoordinateTypeResponse.kt new file mode 100644 index 000000000..b8e3a4774 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/EquatorialCoordinateTypeResponse.kt @@ -0,0 +1,11 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +data class EquatorialCoordinateTypeResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", + @field:JsonProperty("Value") override val value: EquatorialCoordinateType = EquatorialCoordinateType.J2000, +) : AlpacaResponse diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/IntArrayResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/IntArrayResponse.kt new file mode 100644 index 000000000..020e9d72d --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/IntArrayResponse.kt @@ -0,0 +1,18 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +@Suppress("ArrayInDataClass") +data class IntArrayResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", + @field:JsonProperty("Value") override val value: IntArray = EMPTY_ARRAY, +) : AlpacaResponse { + + companion object { + + @JvmStatic private val EMPTY_ARRAY = IntArray(0) + } +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/PierSide.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/PierSide.kt new file mode 100644 index 000000000..e70817fbf --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/PierSide.kt @@ -0,0 +1,9 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonValue + +enum class PierSide(@field:JsonValue val code: Int) { + UNKNOWN(-1), + EAST(0), + WEST(1), +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/PierSideResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/PierSideResponse.kt new file mode 100644 index 000000000..40f621ce2 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/PierSideResponse.kt @@ -0,0 +1,11 @@ +package nebulosa.alpaca.api + +import com.fasterxml.jackson.annotation.JsonProperty + +data class PierSideResponse( + @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, + @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, + @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, + @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", + @field:JsonProperty("Value") override val value: PierSide = PierSide.UNKNOWN, +) : AlpacaResponse diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt index 37f62365d..47c597c20 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt @@ -2,8 +2,10 @@ package nebulosa.alpaca.indi.client import nebulosa.alpaca.api.AlpacaService import nebulosa.alpaca.api.DeviceType -import nebulosa.alpaca.indi.devices.ASCOMDevice -import nebulosa.alpaca.indi.devices.cameras.ASCOMCamera +import nebulosa.alpaca.indi.device.ASCOMDevice +import nebulosa.alpaca.indi.device.ASCOMFilterWheel +import nebulosa.alpaca.indi.device.cameras.ASCOMCamera +import nebulosa.alpaca.indi.device.mounts.ASCOMMount import nebulosa.indi.device.DeviceEvent import nebulosa.indi.device.DeviceEventHandler import nebulosa.indi.device.INDIDeviceProvider @@ -11,11 +13,15 @@ import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.CameraAttached import nebulosa.indi.device.camera.CameraDetached import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.indi.device.filterwheel.FilterWheelAttached +import nebulosa.indi.device.filterwheel.FilterWheelDetached import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.gps.GPS import nebulosa.indi.device.guide.GuideOutput import nebulosa.indi.device.guide.GuideOutputAttached import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.mount.MountAttached +import nebulosa.indi.device.mount.MountDetached import nebulosa.indi.device.thermometer.Thermometer import nebulosa.indi.protocol.INDIProtocol import nebulosa.log.loggerFor @@ -30,6 +36,8 @@ class AlpacaClient( private val service = AlpacaService("http://$host:$port/", httpClient) private val handlers = LinkedHashSet() private val cameras = HashMap() + private val mounts = HashMap() + private val wheels = HashMap() private val guideOutputs = HashMap() override val id = UUID.randomUUID().toString() @@ -111,16 +119,48 @@ class AlpacaClient( val body = response.body() ?: return for (device in body.value) { - if (device.type == DeviceType.CAMERA) { - if (device.uid in cameras) continue - - synchronized(cameras) { - with(ASCOMCamera(device, service.camera, this)) { - cameras[device.uid] = this - LOG.info("camera attached: {}", device.name) - fireOnEventReceived(CameraAttached(this)) + when (device.type) { + DeviceType.CAMERA -> { + if (device.uid in cameras) continue + + synchronized(cameras) { + with(ASCOMCamera(device, service.camera, this)) { + cameras[device.uid] = this + LOG.info("camera attached: {}", device.name) + fireOnEventReceived(CameraAttached(this)) + } } } + DeviceType.TELESCOPE -> { + if (device.uid in mounts) continue + + synchronized(mounts) { + with(ASCOMMount(device, service.telescope, this)) { + mounts[device.uid] = this + LOG.info("mount attached: {}", device.name) + fireOnEventReceived(MountAttached(this)) + } + } + } + DeviceType.FILTER_WHEEL -> { + if (device.uid in wheels) continue + + synchronized(wheels) { + with(ASCOMFilterWheel(device, service.filterWheel, this)) { + wheels[device.uid] = this + LOG.info("filter wheel attached: {}", device.name) + fireOnEventReceived(FilterWheelAttached(this)) + } + } + } + DeviceType.FOCUSER -> Unit + DeviceType.ROTATOR -> Unit + DeviceType.DOME -> Unit + DeviceType.SWITCH -> Unit + DeviceType.COVER_CALIBRATOR -> Unit + DeviceType.OBSERVING_CONDITIONS -> Unit + DeviceType.SAFETY_MONITOR -> Unit + DeviceType.VIDEO -> Unit } } } else { @@ -144,17 +184,17 @@ class AlpacaClient( fireOnEventReceived(CameraDetached(device)) } - // for ((_, device) in mounts) { - // device.close() - // LOG.info("mount detached: {}", device.name) - // fireOnEventReceived(MountDetached(device)) - // } + for ((_, device) in mounts) { + device.close() + LOG.info("mount detached: {}", device.name) + fireOnEventReceived(MountDetached(device)) + } - // for ((_, device) in wheels) { - // device.close() - // LOG.info("filter wheel detached: {}", device.name) - // fireOnEventReceived(FilterWheelDetached(device)) - // } + for ((_, device) in wheels) { + device.close() + LOG.info("filter wheel detached: {}", device.name) + fireOnEventReceived(FilterWheelDetached(device)) + } // for ((_, device) in focusers) { // device.close() @@ -169,8 +209,8 @@ class AlpacaClient( // } cameras.clear() - // mounts.clear() - // wheels.clear() + mounts.clear() + wheels.clear() // focusers.clear() // gps.clear() diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt similarity index 99% rename from nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt rename to nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt index e7fe3292f..3f92ed919 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/ASCOMDevice.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt @@ -1,4 +1,4 @@ -package nebulosa.alpaca.indi.devices +package nebulosa.alpaca.indi.device import nebulosa.alpaca.api.AlpacaDeviceService import nebulosa.alpaca.api.AlpacaResponse diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMFilterWheel.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMFilterWheel.kt new file mode 100644 index 000000000..2cef92aa5 --- /dev/null +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMFilterWheel.kt @@ -0,0 +1,64 @@ +package nebulosa.alpaca.indi.device + +import nebulosa.alpaca.api.AlpacaFilterWheelService +import nebulosa.alpaca.api.ConfiguredDevice +import nebulosa.alpaca.indi.client.AlpacaClient +import nebulosa.indi.device.Device +import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.indi.device.filterwheel.FilterWheelPositionChanged +import nebulosa.indi.protocol.INDIProtocol + +class ASCOMFilterWheel( + override val device: ConfiguredDevice, + override val service: AlpacaFilterWheelService, + override val sender: AlpacaClient, +) : ASCOMDevice(), FilterWheel { + + @Volatile override var count = 0 + private set + @Volatile override var position = -1 + private set + @Volatile override var moving = false + private set + + override fun onConnected() { + processPosition() + } + + override fun onDisconnected() {} + + override fun moveTo(position: Int) { + if (position != this.position) { + service.position(device.number, position).doRequest() + } + } + + override fun refresh(elapsedTimeInSeconds: Long) { + super.refresh(elapsedTimeInSeconds) + + if (connected) { + processPosition() + processMoving() + } + } + + override fun names(names: Iterable) {} + + override fun snoop(devices: Iterable) {} + + override fun handleMessage(message: INDIProtocol) {} + + private fun processMoving() { + } + + private fun processPosition() { + service.position(device.number).doRequest { + if (it.value != position) { + val prevPosition = position + position = it.value + + sender.fireOnEventReceived(FilterWheelPositionChanged(this, prevPosition)) + } + } + } +} diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt similarity index 98% rename from nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt rename to nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt index a45c4bde2..bcb007b88 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ASCOMCamera.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt @@ -1,11 +1,11 @@ -package nebulosa.alpaca.indi.devices.cameras +package nebulosa.alpaca.indi.device.cameras import nebulosa.alpaca.api.AlpacaCameraService import nebulosa.alpaca.api.CameraState import nebulosa.alpaca.api.ConfiguredDevice import nebulosa.alpaca.api.PulseGuideDirection import nebulosa.alpaca.indi.client.AlpacaClient -import nebulosa.alpaca.indi.devices.ASCOMDevice +import nebulosa.alpaca.indi.device.ASCOMDevice import nebulosa.common.concurrency.latch.CountUpDownLatch import nebulosa.fits.* import nebulosa.imaging.algorithms.transformation.CfaPattern @@ -208,13 +208,15 @@ data class ASCOMCamera( } private fun pulseGuide(direction: PulseGuideDirection, duration: Duration) { - val durationInMilliseconds = duration.toMillis() + if (canPulseGuide) { + val durationInMilliseconds = duration.toMillis() - service.pulseGuide(device.number, direction, durationInMilliseconds).doRequest() ?: return + service.pulseGuide(device.number, direction, durationInMilliseconds).doRequest() ?: return - if (durationInMilliseconds > 0) { - pulseGuiding = true - sender.fireOnEventReceived(GuideOutputPulsingChanged(this)) + if (durationInMilliseconds > 0) { + pulseGuiding = true + sender.fireOnEventReceived(GuideOutputPulsingChanged(this)) + } } } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ImageArrayElementType.kt similarity index 91% rename from nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt rename to nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ImageArrayElementType.kt index dc8c69111..1cce8be78 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/devices/cameras/ImageArrayElementType.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ImageArrayElementType.kt @@ -1,4 +1,4 @@ -package nebulosa.alpaca.indi.devices.cameras +package nebulosa.alpaca.indi.device.cameras import nebulosa.fits.Bitpix diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt new file mode 100644 index 000000000..6ddf093fa --- /dev/null +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt @@ -0,0 +1,204 @@ +package nebulosa.alpaca.indi.device.mounts + +import nebulosa.alpaca.api.AlpacaTelescopeService +import nebulosa.alpaca.api.ConfiguredDevice +import nebulosa.alpaca.api.DriveRate +import nebulosa.alpaca.api.PulseGuideDirection +import nebulosa.alpaca.indi.client.AlpacaClient +import nebulosa.alpaca.indi.device.ASCOMDevice +import nebulosa.indi.device.Device +import nebulosa.indi.device.guide.GuideOutputPulsingChanged +import nebulosa.indi.device.mount.* +import nebulosa.indi.protocol.INDIProtocol +import nebulosa.math.* +import java.time.Duration +import java.time.OffsetDateTime + +class ASCOMMount( + override val device: ConfiguredDevice, + override val service: AlpacaTelescopeService, + override val sender: AlpacaClient, +) : ASCOMDevice(), Mount { + + @Volatile override var slewing = false + private set + @Volatile override var tracking = false + private set + @Volatile override var parking = false + private set + @Volatile override var parked = false + private set + @Volatile override var canAbort = false + private set + @Volatile override var canSync = false + private set + @Volatile override var canGoTo = false + private set + @Volatile override var canPark = false + private set + @Volatile override var canHome = false + private set + @Volatile override var slewRates = emptyList() + private set + @Volatile override var slewRate: SlewRate? = null + private set + @Volatile override var mountType = MountType.EQ_GEM + private set + @Volatile override var trackModes = emptyList() + private set + @Volatile override var trackMode = TrackMode.SIDEREAL + private set + @Volatile override var pierSide = PierSide.NEITHER + private set + @Volatile override var guideRateWE = 0.0 + private set + @Volatile override var guideRateNS = 0.0 + private set + @Volatile override var rightAscension = 0.0 + private set + @Volatile override var declination = 0.0 + private set + + @Volatile override var canPulseGuide = false + private set + @Volatile override var pulseGuiding = false + private set + + @Volatile override var hasGPS = false + private set + @Volatile override var longitude = 0.0 + private set + @Volatile override var latitude = 0.0 + private set + @Volatile override var elevation = 0.0 + private set + @Volatile override var dateTime = OffsetDateTime.now()!! + private set + + override fun onConnected() {} + + override fun onDisconnected() {} + + override fun snoop(devices: Iterable) {} + + override fun park() { + if (canPark) { + service.park(device.number).doRequest() + } + } + + override fun unpark() { + if (canPark) { + service.unpark(device.number).doRequest() + } + } + + private fun pulseGuide(direction: PulseGuideDirection, duration: Duration) { + if (canPulseGuide) { + val durationInMilliseconds = duration.toMillis() + + service.pulseGuide(device.number, direction, durationInMilliseconds).doRequest() ?: return + + if (durationInMilliseconds > 0) { + pulseGuiding = true + sender.fireOnEventReceived(GuideOutputPulsingChanged(this)) + } + } + } + + override fun guideNorth(duration: Duration) { + pulseGuide(PulseGuideDirection.NORTH, duration) + } + + override fun guideSouth(duration: Duration) { + pulseGuide(PulseGuideDirection.SOUTH, duration) + } + + override fun guideEast(duration: Duration) { + pulseGuide(PulseGuideDirection.EAST, duration) + } + + override fun guideWest(duration: Duration) { + pulseGuide(PulseGuideDirection.WEST, duration) + } + + override fun tracking(enable: Boolean) { + service.tracking(device.number, enable).doRequest() + } + + override fun sync(ra: Angle, dec: Angle) { + if (canSync) { + service.syncToCoordinates(device.number, ra.toHours, dec.toDegrees).doRequest() + } + } + + override fun syncJ2000(ra: Angle, dec: Angle) { + TODO("Not yet implemented") + } + + override fun slewTo(ra: Angle, dec: Angle) { + service.slewToCoordinates(device.number, ra.toHours, dec.toDegrees).doRequest() + } + + override fun slewToJ2000(ra: Angle, dec: Angle) { + TODO("Not yet implemented") + } + + override fun goTo(ra: Angle, dec: Angle) { + TODO("Not yet implemented") + } + + override fun goToJ2000(ra: Angle, dec: Angle) { + TODO("Not yet implemented") + } + + override fun home() { + if (canHome) { + service.findHome(device.number).doRequest() + } + } + + override fun abortMotion() { + if (canAbort) { + service.abortSlew(device.number).doRequest() + } + } + + override fun trackMode(mode: TrackMode) { + if (mode != TrackMode.CUSTOM) { + service.trackingRate(device.number, DriveRate.entries[mode.ordinal]) + } + } + + override fun slewRate(rate: SlewRate) { + TODO("Not yet implemented") + } + + override fun moveNorth(enabled: Boolean) { + TODO("Not yet implemented") + } + + override fun moveSouth(enabled: Boolean) { + TODO("Not yet implemented") + } + + override fun moveWest(enabled: Boolean) { + TODO("Not yet implemented") + } + + override fun moveEast(enabled: Boolean) { + TODO("Not yet implemented") + } + + override fun coordinates(longitude: Angle, latitude: Angle, elevation: Distance) { + service.siteLongitude(device.number, longitude.toDegrees).doRequest {} && + service.siteLatitude(device.number, latitude.toDegrees).doRequest {} && + service.siteElevation(device.number, elevation.toMeters).doRequest {} + } + + override fun dateTime(dateTime: OffsetDateTime) { + service.utcDate(device.number, dateTime.toInstant()) + } + + override fun handleMessage(message: INDIProtocol) {} +} diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt index 70d4c33ba..84def101f 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt @@ -6,12 +6,12 @@ import nebulosa.indi.client.device.FilterWheelDevice import nebulosa.indi.client.device.FocuserDevice import nebulosa.indi.client.device.GPSDevice import nebulosa.indi.client.device.INDIDeviceProtocolHandler -import nebulosa.indi.client.device.camera.AsiCamera -import nebulosa.indi.client.device.camera.CameraDevice -import nebulosa.indi.client.device.camera.SVBonyCamera -import nebulosa.indi.client.device.camera.SimCamera -import nebulosa.indi.client.device.mount.IoptronV3Mount -import nebulosa.indi.client.device.mount.MountDevice +import nebulosa.indi.client.device.cameras.AsiCamera +import nebulosa.indi.client.device.cameras.CameraDevice +import nebulosa.indi.client.device.cameras.SVBonyCamera +import nebulosa.indi.client.device.cameras.SimCamera +import nebulosa.indi.client.device.mounts.IoptronV3Mount +import nebulosa.indi.client.device.mounts.MountDevice import nebulosa.indi.device.Device import nebulosa.indi.device.INDIDeviceProvider import nebulosa.indi.device.camera.Camera diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt index 945746e6b..e04510266 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt @@ -63,7 +63,7 @@ internal open class FilterWheelDevice( } } - override fun syncNames(names: Iterable) { + override fun names(names: Iterable) { sendNewText("FILTER_NAME", names.mapIndexed { i, name -> "FILTER_SLOT_NAME_${i + 1}" to name }) } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt index 5d3ca68a0..3a4c26fd7 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt @@ -1,7 +1,7 @@ package nebulosa.indi.client.device import nebulosa.indi.client.INDIClient -import nebulosa.indi.client.device.camera.CameraDevice +import nebulosa.indi.client.device.cameras.CameraDevice import nebulosa.indi.device.* import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.dome.Dome diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/AsiCamera.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/AsiCamera.kt similarity index 96% rename from nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/AsiCamera.kt rename to nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/AsiCamera.kt index 0d1820a8f..a429cbf00 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/AsiCamera.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/AsiCamera.kt @@ -1,4 +1,4 @@ -package nebulosa.indi.client.device.camera +package nebulosa.indi.client.device.cameras import nebulosa.indi.client.INDIClient import nebulosa.indi.protocol.INDIProtocol diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/CameraDevice.kt similarity index 99% rename from nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt rename to nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/CameraDevice.kt index 0ce91df08..bd0a4accd 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/CameraDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/CameraDevice.kt @@ -1,4 +1,4 @@ -package nebulosa.indi.client.device.camera +package nebulosa.indi.client.device.cameras import nebulosa.imaging.algorithms.transformation.CfaPattern import nebulosa.indi.client.INDIClient diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/SVBonyCamera.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SVBonyCamera.kt similarity index 97% rename from nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/SVBonyCamera.kt rename to nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SVBonyCamera.kt index 172e4359b..0edd4b47f 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/SVBonyCamera.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SVBonyCamera.kt @@ -1,4 +1,4 @@ -package nebulosa.indi.client.device.camera +package nebulosa.indi.client.device.cameras import nebulosa.indi.client.INDIClient import nebulosa.indi.protocol.INDIProtocol diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/SimCamera.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SimCamera.kt similarity index 95% rename from nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/SimCamera.kt rename to nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SimCamera.kt index b2298bf88..fe647beed 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/camera/SimCamera.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SimCamera.kt @@ -1,4 +1,4 @@ -package nebulosa.indi.client.device.camera +package nebulosa.indi.client.device.cameras import nebulosa.indi.client.INDIClient import nebulosa.indi.protocol.INDIProtocol diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/IoptronV3Mount.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/IoptronV3Mount.kt similarity index 94% rename from nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/IoptronV3Mount.kt rename to nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/IoptronV3Mount.kt index e7f65f1cb..63777d120 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/IoptronV3Mount.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/IoptronV3Mount.kt @@ -1,4 +1,4 @@ -package nebulosa.indi.client.device.mount +package nebulosa.indi.client.device.mounts import nebulosa.indi.client.INDIClient import nebulosa.indi.device.mount.MountCanHomeChanged diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/MountDevice.kt similarity index 99% rename from nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt rename to nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/MountDevice.kt index dc6deaf3a..4011824e3 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mount/MountDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/MountDevice.kt @@ -1,4 +1,4 @@ -package nebulosa.indi.client.device.mount +package nebulosa.indi.client.device.mounts import nebulosa.indi.client.INDIClient import nebulosa.indi.client.device.INDIDevice diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheel.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheel.kt index f416c412c..45025f2da 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheel.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheel.kt @@ -12,7 +12,7 @@ interface FilterWheel : Device { fun moveTo(position: Int) - fun syncNames(names: Iterable) + fun names(names: Iterable) companion object { From 7b618fab2b0cd1ab5e5c03528c126613f885af4d Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sun, 18 Feb 2024 17:57:00 -0300 Subject: [PATCH 38/87] [api][desktop]: Make connectionStatus endpoint return the connection details --- .../api/connection/ConnectionController.kt | 7 +++++- .../api/connection/ConnectionService.kt | 22 +++++++++++++++++-- .../api/connection/ConnectionStatus.kt | 7 ++++++ desktop/src/app/home/home.component.ts | 6 ++--- desktop/src/shared/services/api.service.ts | 8 +++++-- desktop/src/shared/types/home.types.ts | 2 ++ .../alpaca/indi/client/AlpacaClient.kt | 6 ++--- .../kotlin/nebulosa/indi/client/INDIClient.kt | 2 +- 8 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 api/src/main/kotlin/nebulosa/api/connection/ConnectionStatus.kt diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionController.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionController.kt index 91610ed97..0bd82df5a 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionController.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionController.kt @@ -25,8 +25,13 @@ class ConnectionController( connectionService.disconnect(id) } + @GetMapping + fun connectionStatuses(): List { + return connectionService.connectionStatuses() + } + @GetMapping("{id}") - fun connectionStatus(@PathVariable id: String): Boolean { + fun connectionStatus(@PathVariable id: String): ConnectionStatus? { return connectionService.connectionStatus(id) } } diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt index b76d73498..b0c72ab8a 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt @@ -2,6 +2,7 @@ package nebulosa.api.connection import nebulosa.alpaca.indi.client.AlpacaClient import nebulosa.indi.client.INDIClient +import nebulosa.indi.client.connection.INDISocketConnection import nebulosa.indi.device.Device import nebulosa.indi.device.INDIDeviceProvider import nebulosa.indi.device.camera.Camera @@ -28,8 +29,25 @@ class ConnectionService( private val providers = LinkedHashMap() - fun connectionStatus(id: String): Boolean { - return id in providers + fun connectionStatuses(): List { + return providers.keys.map { connectionStatus(it)!! } + } + + fun connectionStatus(id: String): ConnectionStatus? { + when (val client = providers[id]) { + is INDIClient -> { + when (val connection = client.connection) { + is INDISocketConnection -> { + return ConnectionStatus(id, ConnectionType.INDI, connection.host, connection.port) + } + } + } + is AlpacaClient -> { + return ConnectionStatus(id, ConnectionType.INDI, client.host, client.port) + } + } + + return null } @Synchronized diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionStatus.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionStatus.kt new file mode 100644 index 000000000..5d3802bc3 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionStatus.kt @@ -0,0 +1,7 @@ +package nebulosa.api.connection + +data class ConnectionStatus( + val id: String, + val type: ConnectionType, + val host: String, val port: Int, +) diff --git a/desktop/src/app/home/home.component.ts b/desktop/src/app/home/home.component.ts index 73d9fc422..14c62e8b8 100644 --- a/desktop/src/app/home/home.component.ts +++ b/desktop/src/app/home/home.component.ts @@ -366,13 +366,13 @@ export class HomeComponent implements AfterContentInit, OnDestroy { private async updateConnection() { if (this.connection && this.connection.id) { try { - const connected = await this.api.connectionStatus(this.connection.id!) + const status = await this.api.connectionStatus(this.connection.id!) - if (connected && !this.connection.connected) { + if (status && !this.connection.connected) { this.connection.connectedAt = Date.now() this.preference.connections.set(this.connections) this.connection.connected = true - } else if (!connected) { + } else if (!status) { this.connection.connected = false } } catch { diff --git a/desktop/src/shared/services/api.service.ts b/desktop/src/shared/services/api.service.ts index fc8989808..0cdd19e1f 100644 --- a/desktop/src/shared/services/api.service.ts +++ b/desktop/src/shared/services/api.service.ts @@ -9,7 +9,7 @@ import { FlatWizardRequest } from '../types/flat-wizard.types' import { Focuser } from '../types/focuser.types' import { HipsSurvey } from '../types/framing.types' import { GuideDirection, GuideOutput, Guider, GuiderHistoryStep, SettleInfo } from '../types/guider.types' -import { ConnectionType } from '../types/home.types' +import { ConnectionStatus, ConnectionType } from '../types/home.types' import { CoordinateInterpolation, DetectedStar, ImageAnnotation, ImageChannel, ImageInfo, ImageSolved, SCNRProtectionMethod } from '../types/image.types' import { CelestialLocationType, Mount, SlewRate, TrackMode } from '../types/mount.types' import { SequencePlan } from '../types/sequencer.types' @@ -37,8 +37,12 @@ export class ApiService { return this.http.delete(`connection/${id}`) } + connectionStatuses() { + return this.http.get(`connection`) + } + connectionStatus(id: string) { - return this.http.get(`connection/${id}`) + return this.http.get(`connection/${id}`) } // CAMERA diff --git a/desktop/src/shared/types/home.types.ts b/desktop/src/shared/types/home.types.ts index 7540b32b4..115668b39 100644 --- a/desktop/src/shared/types/home.types.ts +++ b/desktop/src/shared/types/home.types.ts @@ -15,6 +15,8 @@ export interface ConnectionDetails { id?: string } +export type ConnectionStatus = Omit, 'connected' | 'name' | 'connectedAt'> + export const EMPTY_CONNECTION_DETAILS: ConnectionDetails = { name: '', host: 'localhost', diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt index 47c597c20..3444da374 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt @@ -28,9 +28,9 @@ import nebulosa.log.loggerFor import okhttp3.OkHttpClient import java.util.* -class AlpacaClient( - host: String, port: Int, - httpClient: OkHttpClient? = null, +data class AlpacaClient( + val host: String, val port: Int, + private val httpClient: OkHttpClient? = null, ) : INDIDeviceProvider { private val service = AlpacaService("http://$host:$port/", httpClient) diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt index 84def101f..6847cc819 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt @@ -28,7 +28,7 @@ import nebulosa.log.debug import nebulosa.log.loggerFor import java.util.* -class INDIClient(private val connection: INDIConnection) : INDIDeviceProtocolHandler(), INDIDeviceProvider { +data class INDIClient(val connection: INDIConnection) : INDIDeviceProtocolHandler(), INDIDeviceProvider { constructor( host: String, From 04a314353cf1c98bd8cefbde0b1f83f5351e9e0c Mon Sep 17 00:00:00 2001 From: tiagohm Date: Tue, 20 Feb 2024 09:18:15 -0300 Subject: [PATCH 39/87] [api]: Add support to ASCOM Alpaca API --- api/src/main/kotlin/nebulosa/api/README.md | 2 +- .../api/focusers/FocuserSerializer.kt | 2 +- desktop/src/app/focuser/focuser.component.ts | 4 +- desktop/src/shared/types/focuser.types.ts | 4 +- desktop/src/shared/types/wheel.types.ts | 4 +- .../alpaca/api/AlpacaFilterWheelService.kt | 2 +- .../alpaca/api/AlpacaFocuserService.kt | 52 ++++++ .../nebulosa/alpaca/api/AlpacaService.kt | 2 + .../alpaca/api/AlpacaTelescopeService.kt | 2 +- .../alpaca/indi/client/AlpacaClient.kt | 38 +++- .../alpaca/indi/device/cameras/ASCOMCamera.kt | 22 +-- .../indi/device/focusers/ASCOMFocuser.kt | 172 ++++++++++++++++++ .../alpaca/indi/device/mounts/ASCOMMount.kt | 159 ++++++++++++++-- .../device/{ => wheels}/ASCOMFilterWheel.kt | 28 ++- .../kotlin/nebulosa/indi/client/INDIClient.kt | 16 +- .../nebulosa/indi/client/device/INDIDevice.kt | 4 +- .../indi/client/device/cameras/AsiCamera.kt | 2 +- .../{CameraDevice.kt => INDICamera.kt} | 4 +- .../client/device/cameras/SVBonyCamera.kt | 2 +- .../indi/client/device/cameras/SimCamera.kt | 2 +- .../INDIFocuser.kt} | 11 +- .../mounts/{MountDevice.kt => INDIMount.kt} | 4 +- .../client/device/mounts/IoptronV3Mount.kt | 2 +- .../INDIFilterWheel.kt} | 31 +++- .../indi/device/filterwheel/FilterWheel.kt | 2 + .../filterwheel/FilterWheelNamesChanged.kt | 5 + .../nebulosa/indi/device/focuser/Focuser.kt | 2 +- 27 files changed, 496 insertions(+), 84 deletions(-) create mode 100644 nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFocuserService.kt create mode 100644 nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/focusers/ASCOMFocuser.kt rename nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/{ => wheels}/ASCOMFilterWheel.kt (69%) rename nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/{CameraDevice.kt => INDICamera.kt} (99%) rename nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/{FocuserDevice.kt => focusers/INDIFocuser.kt} (96%) rename nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/{MountDevice.kt => INDIMount.kt} (99%) rename nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/{FilterWheelDevice.kt => wheels/INDIFilterWheel.kt} (73%) create mode 100644 nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheelNamesChanged.kt diff --git a/api/src/main/kotlin/nebulosa/api/README.md b/api/src/main/kotlin/nebulosa/api/README.md index 5b20496b5..7a6187f39 100644 --- a/api/src/main/kotlin/nebulosa/api/README.md +++ b/api/src/main/kotlin/nebulosa/api/README.md @@ -152,7 +152,7 @@ URL: `localhost:{PORT}/ws` "canRelativeMove": false, "canAbort": false, "canReverse": false, - "reverse": false, + "reversed": false, "canSync": false, "hasBacklash": false, "maxPosition": 0, diff --git a/api/src/main/kotlin/nebulosa/api/focusers/FocuserSerializer.kt b/api/src/main/kotlin/nebulosa/api/focusers/FocuserSerializer.kt index 5162f3dd1..ab76e2024 100644 --- a/api/src/main/kotlin/nebulosa/api/focusers/FocuserSerializer.kt +++ b/api/src/main/kotlin/nebulosa/api/focusers/FocuserSerializer.kt @@ -21,7 +21,7 @@ class FocuserSerializer : StdSerializer(Focuser::class.java) { gen.writeBooleanField("canRelativeMove", value.canRelativeMove) gen.writeBooleanField("canAbort", value.canAbort) gen.writeBooleanField("canReverse", value.canReverse) - gen.writeBooleanField("reverse", value.reverse) + gen.writeBooleanField("reversed", value.reversed) gen.writeBooleanField("canSync", value.canSync) gen.writeBooleanField("hasBacklash", value.hasBacklash) gen.writeNumberField("maxPosition", value.maxPosition) diff --git a/desktop/src/app/focuser/focuser.component.ts b/desktop/src/app/focuser/focuser.component.ts index 1a6d91ac1..43266c857 100644 --- a/desktop/src/app/focuser/focuser.component.ts +++ b/desktop/src/app/focuser/focuser.component.ts @@ -23,7 +23,7 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { canRelativeMove = false canAbort = false canReverse = false - reverse = false + reversed = false canSync = false hasBacklash = false maxPosition = 0 @@ -133,7 +133,7 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { this.canRelativeMove = this.focuser.canRelativeMove this.canAbort = this.focuser.canAbort this.canReverse = this.focuser.canReverse - this.reverse = this.focuser.reverse + this.reversed = this.focuser.reversed this.canSync = this.focuser.canSync this.hasBacklash = this.focuser.hasBacklash this.maxPosition = this.focuser.maxPosition diff --git a/desktop/src/shared/types/focuser.types.ts b/desktop/src/shared/types/focuser.types.ts index fbcffd8c4..eb1e2f8ca 100644 --- a/desktop/src/shared/types/focuser.types.ts +++ b/desktop/src/shared/types/focuser.types.ts @@ -8,7 +8,7 @@ export interface Focuser extends Device, Thermometer { canRelativeMove: boolean canAbort: boolean canReverse: boolean - reverse: boolean + reversed: boolean canSync: boolean hasBacklash: boolean maxPosition: number @@ -23,7 +23,7 @@ export const EMPTY_FOCUSER: Focuser = { canRelativeMove: false, canAbort: false, canReverse: false, - reverse: false, + reversed: false, canSync: false, hasBacklash: false, maxPosition: 0, diff --git a/desktop/src/shared/types/wheel.types.ts b/desktop/src/shared/types/wheel.types.ts index 1a635506c..141bdf170 100644 --- a/desktop/src/shared/types/wheel.types.ts +++ b/desktop/src/shared/types/wheel.types.ts @@ -7,6 +7,7 @@ export interface FilterWheel extends Device { count: number position: number moving: boolean + names: string[] } export const EMPTY_WHEEL: FilterWheel = { @@ -16,7 +17,8 @@ export const EMPTY_WHEEL: FilterWheel = { position: 0, moving: false, name: '', - connected: false + connected: false, + names: [], } export interface WheelDialogInput { diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFilterWheelService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFilterWheelService.kt index 3d5922a55..8a3e72f44 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFilterWheelService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFilterWheelService.kt @@ -16,7 +16,7 @@ interface AlpacaFilterWheelService : AlpacaDeviceService { fun focusOffsets(@Path("id") id: Int): Call @GET("api/v1/filterwheel/{id}/names") - fun names(@Path("id") id: Int): Call> + fun names(@Path("id") id: Int): Call> @GET("api/v1/filterwheel/{id}/position") fun position(@Path("id") id: Int): Call diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFocuserService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFocuserService.kt new file mode 100644 index 000000000..40556cbf1 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFocuserService.kt @@ -0,0 +1,52 @@ +package nebulosa.alpaca.api + +import retrofit2.Call +import retrofit2.http.* + +interface AlpacaFocuserService : AlpacaDeviceService { + + @GET("api/v1/focuser/{id}/connected") + override fun isConnected(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/focuser/{id}/connected") + override fun connect(@Path("id") id: Int, @Field("Connected") connected: Boolean): Call + + @GET("api/v1/focuser/{id}/absolute") + fun canAbsolute(@Path("id") id: Int): Call + + @GET("api/v1/focuser/{id}/ismoving") + fun isMoving(@Path("id") id: Int): Call + + @GET("api/v1/focuser/{id}/maxincrement") + fun maxIncrement(@Path("id") id: Int): Call + + @GET("api/v1/focuser/{id}/maxstep") + fun maxStep(@Path("id") id: Int): Call + + @GET("api/v1/focuser/{id}/position") + fun position(@Path("id") id: Int): Call + + @GET("api/v1/focuser/{id}/stepsize") + fun stepSize(@Path("id") id: Int): Call + + @GET("api/v1/focuser/{id}/tempcomp") + fun temperatureCompensation(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/focuser/{id}/tempcomp") + fun temperatureCompensation(@Path("id") id: Int, @Field("TempComp") enabled: Boolean): Call + + @GET("api/v1/focuser/{id}/tempcompavailable") + fun hasTemperatureCompensation(@Path("id") id: Int): Call + + @GET("api/v1/focuser/{id}/temperature") + fun temperature(@Path("id") id: Int): Call + + @PUT("api/v1/focuser/{id}/halt") + fun halt(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/focuser/{id}/move") + fun move(@Path("id") id: Int, @Field("Position") position: Int): Call +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt index e4869ffa6..94c180c0a 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt @@ -22,4 +22,6 @@ class AlpacaService( val telescope by lazy { retrofit.create() } val filterWheel by lazy { retrofit.create() } + + val focuser by lazy { retrofit.create() } } diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt index 810e20977..f66cddcf1 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt @@ -157,7 +157,7 @@ interface AlpacaTelescopeService : AlpacaGuideOutputService { fun siteLatitude(@Path("id") id: Int, @Field("SiteLatitude") latitude: Double): Call @GET("api/v1/telescope/{id}/sitelongitude") - fun siteLongitude(@Path("id") id: Int): Call + fun siteLongitude(@Path("id") id: Int): Call @FormUrlEncoded @PUT("api/v1/telescope/{id}/sitelongitude") diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt index 3444da374..639a1c347 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt @@ -3,9 +3,10 @@ package nebulosa.alpaca.indi.client import nebulosa.alpaca.api.AlpacaService import nebulosa.alpaca.api.DeviceType import nebulosa.alpaca.indi.device.ASCOMDevice -import nebulosa.alpaca.indi.device.ASCOMFilterWheel import nebulosa.alpaca.indi.device.cameras.ASCOMCamera +import nebulosa.alpaca.indi.device.focusers.ASCOMFocuser import nebulosa.alpaca.indi.device.mounts.ASCOMMount +import nebulosa.alpaca.indi.device.wheels.ASCOMFilterWheel import nebulosa.indi.device.DeviceEvent import nebulosa.indi.device.DeviceEventHandler import nebulosa.indi.device.INDIDeviceProvider @@ -16,9 +17,12 @@ import nebulosa.indi.device.filterwheel.FilterWheel import nebulosa.indi.device.filterwheel.FilterWheelAttached import nebulosa.indi.device.filterwheel.FilterWheelDetached import nebulosa.indi.device.focuser.Focuser +import nebulosa.indi.device.focuser.FocuserAttached +import nebulosa.indi.device.focuser.FocuserDetached import nebulosa.indi.device.gps.GPS import nebulosa.indi.device.guide.GuideOutput import nebulosa.indi.device.guide.GuideOutputAttached +import nebulosa.indi.device.guide.GuideOutputDetached import nebulosa.indi.device.mount.Mount import nebulosa.indi.device.mount.MountAttached import nebulosa.indi.device.mount.MountDetached @@ -38,6 +42,7 @@ data class AlpacaClient( private val cameras = HashMap() private val mounts = HashMap() private val wheels = HashMap() + private val focusers = HashMap() private val guideOutputs = HashMap() override val id = UUID.randomUUID().toString() @@ -153,7 +158,17 @@ data class AlpacaClient( } } } - DeviceType.FOCUSER -> Unit + DeviceType.FOCUSER -> { + if (device.uid in focusers) continue + + synchronized(focusers) { + with(ASCOMFocuser(device, service.focuser, this)) { + focusers[device.uid] = this + LOG.info("focuser attached: {}", device.name) + fireOnEventReceived(FocuserAttached(this)) + } + } + } DeviceType.ROTATOR -> Unit DeviceType.DOME -> Unit DeviceType.SWITCH -> Unit @@ -177,6 +192,13 @@ data class AlpacaClient( } } + internal fun unregisterGuideOutput(device: GuideOutput) { + if (device.name in guideOutputs) { + guideOutputs.remove(device.name) + fireOnEventReceived(GuideOutputDetached(device)) + } + } + override fun close() { for ((_, device) in cameras) { device.close() @@ -196,11 +218,11 @@ data class AlpacaClient( fireOnEventReceived(FilterWheelDetached(device)) } - // for ((_, device) in focusers) { - // device.close() - // LOG.info("focuser detached: {}", device.name) - // fireOnEventReceived(FocuserDetached(device)) - // } + for ((_, device) in focusers) { + device.close() + LOG.info("focuser detached: {}", device.name) + fireOnEventReceived(FocuserDetached(device)) + } // for ((_, device) in gps) { // device.close() @@ -211,7 +233,7 @@ data class AlpacaClient( cameras.clear() mounts.clear() wheels.clear() - // focusers.clear() + focusers.clear() // gps.clear() handlers.clear() diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt index bcb007b88..f6929f99d 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt @@ -570,35 +570,31 @@ data class ASCOMCamera( private fun processCapabilities() { service.canAbortExposure(device.number).doRequest { - if (it.value != canAbort) { - canAbort = it.value - + if (it.value) { + canAbort = true sender.fireOnEventReceived(CameraCanAbortChanged(this)) } } service.canCoolerPower(device.number).doRequest { - if (it.value != hasCoolerControl) { - hasCoolerControl = it.value - + if (it.value) { + hasCoolerControl = true sender.fireOnEventReceived(CameraCoolerControlChanged(this)) } } service.canPulseGuide(device.number).doRequest { - if (it.value != canPulseGuide) { - canPulseGuide = it.value - + if (it.value) { + canPulseGuide = true sender.registerGuideOutput(this) - LOG.info("guide output attached: {}", name) } } service.canSetCCDTemperature(device.number).doRequest { - if (it.value != canSetTemperature) { - canSetTemperature = it.value - hasCooler = canSetTemperature + if (it.value) { + canSetTemperature = true + hasCooler = true sender.fireOnEventReceived(CameraHasCoolerChanged(this)) sender.fireOnEventReceived(CameraCanSetTemperatureChanged(this)) diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/focusers/ASCOMFocuser.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/focusers/ASCOMFocuser.kt new file mode 100644 index 000000000..70247550c --- /dev/null +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/focusers/ASCOMFocuser.kt @@ -0,0 +1,172 @@ +package nebulosa.alpaca.indi.device.focusers + +import nebulosa.alpaca.api.AlpacaFocuserService +import nebulosa.alpaca.api.ConfiguredDevice +import nebulosa.alpaca.indi.client.AlpacaClient +import nebulosa.alpaca.indi.device.ASCOMDevice +import nebulosa.indi.device.Device +import nebulosa.indi.device.focuser.Focuser +import nebulosa.indi.device.focuser.FocuserCanAbsoluteMoveChanged +import nebulosa.indi.device.focuser.FocuserMovingChanged +import nebulosa.indi.device.focuser.FocuserPositionChanged +import nebulosa.indi.device.thermometer.ThermometerAttached +import nebulosa.indi.device.thermometer.ThermometerDetached +import nebulosa.indi.device.thermometer.ThermometerTemperatureChanged +import nebulosa.indi.protocol.INDIProtocol + +data class ASCOMFocuser( + override val device: ConfiguredDevice, + override val service: AlpacaFocuserService, + override val sender: AlpacaClient, +) : ASCOMDevice(), Focuser { + + @Volatile override var moving = false + private set + @Volatile override var position = 0 + private set + @Volatile override var canAbsoluteMove = false + private set + @Volatile override var canRelativeMove = false + private set + @Volatile override var canAbort = false + private set + @Volatile override var canReverse = false + private set + @Volatile override var reversed = false + private set + @Volatile override var canSync = false + private set + @Volatile override var hasBacklash = false + private set + @Volatile override var maxPosition = 0 + private set + + @Volatile override var hasThermometer = false + private set + @Volatile override var temperature = 0.0 + private set + + override fun moveFocusIn(steps: Int) { + if (canAbsoluteMove) { + service.move(device.number, position + steps).doRequest() + } else { + service.move(device.number, steps).doRequest() + } + } + + override fun moveFocusOut(steps: Int) { + if (canAbsoluteMove) { + service.move(device.number, position - steps).doRequest() + } else { + service.move(device.number, -steps).doRequest() + } + } + + override fun moveFocusTo(steps: Int) { + } + + override fun abortFocus() { + service.halt(device.number).doRequest() + } + + override fun reverseFocus(enable: Boolean) { + } + + override fun syncFocusTo(steps: Int) { + } + + override fun snoop(devices: Iterable) { + } + + override fun handleMessage(message: INDIProtocol) { + } + + override fun onConnected() { + processCapabilities() + processPosition() + processTemperature() + } + + override fun onDisconnected() { + } + + override fun reset() { + super.reset() + + moving = false + position = 0 + canAbsoluteMove = false + canRelativeMove = false + canAbort = false + canReverse = false + reversed = false + canSync = false + hasBacklash = false + maxPosition = 0 + hasThermometer = false + temperature = 0.0 + } + + override fun close() { + super.close() + + if (hasThermometer) { + hasThermometer = false + sender.fireOnEventReceived(ThermometerDetached(this)) + } + } + + override fun refresh(elapsedTimeInSeconds: Long) { + super.refresh(elapsedTimeInSeconds) + + processMoving() + processPosition() + processTemperature() + } + + private fun processCapabilities() { + service.canAbsolute(device.number).doRequest { + if (it.value) { + canAbsoluteMove = true + sender.fireOnEventReceived(FocuserCanAbsoluteMoveChanged(this)) + } + } + + service.temperature(device.number).doRequest { + hasThermometer = true + sender.fireOnEventReceived(ThermometerAttached(this)) + } + } + + private fun processMoving() { + service.isMoving(device.number).doRequest { + if (it.value != moving) { + moving = it.value + + sender.fireOnEventReceived(FocuserMovingChanged(this)) + } + } + } + + private fun processPosition() { + service.position(device.number).doRequest { + if (it.value != position) { + position = it.value + + sender.fireOnEventReceived(FocuserPositionChanged(this)) + } + } + } + + private fun processTemperature() { + if (hasThermometer) { + service.temperature(device.number).doRequest { + if (it.value != temperature) { + temperature = it.value + + sender.fireOnEventReceived(ThermometerTemperatureChanged(this)) + } + } + } + } +} diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt index 6ddf093fa..b8f6e58f0 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt @@ -11,10 +11,12 @@ import nebulosa.indi.device.guide.GuideOutputPulsingChanged import nebulosa.indi.device.mount.* import nebulosa.indi.protocol.INDIProtocol import nebulosa.math.* +import nebulosa.nova.position.ICRF +import nebulosa.time.CurrentTime import java.time.Duration import java.time.OffsetDateTime -class ASCOMMount( +data class ASCOMMount( override val device: ConfiguredDevice, override val service: AlpacaTelescopeService, override val sender: AlpacaClient, @@ -28,7 +30,7 @@ class ASCOMMount( private set @Volatile override var parked = false private set - @Volatile override var canAbort = false + @Volatile override var canAbort = true private set @Volatile override var canSync = false private set @@ -75,12 +77,6 @@ class ASCOMMount( @Volatile override var dateTime = OffsetDateTime.now()!! private set - override fun onConnected() {} - - override fun onDisconnected() {} - - override fun snoop(devices: Iterable) {} - override fun park() { if (canPark) { service.park(device.number).doRequest() @@ -133,7 +129,11 @@ class ASCOMMount( } override fun syncJ2000(ra: Angle, dec: Angle) { - TODO("Not yet implemented") + if (canSync) { + with(ICRF.equatorial(ra, dec, epoch = CurrentTime).equatorial()) { + sync(longitude.normalized, latitude) + } + } } override fun slewTo(ra: Angle, dec: Angle) { @@ -141,15 +141,19 @@ class ASCOMMount( } override fun slewToJ2000(ra: Angle, dec: Angle) { - TODO("Not yet implemented") + if (canSync) { + with(ICRF.equatorial(ra, dec, epoch = CurrentTime).equatorial()) { + slewTo(longitude.normalized, latitude) + } + } } override fun goTo(ra: Angle, dec: Angle) { - TODO("Not yet implemented") + slewTo(ra, dec) } override fun goToJ2000(ra: Angle, dec: Angle) { - TODO("Not yet implemented") + slewToJ2000(ra, dec) } override fun home() { @@ -171,23 +175,18 @@ class ASCOMMount( } override fun slewRate(rate: SlewRate) { - TODO("Not yet implemented") } override fun moveNorth(enabled: Boolean) { - TODO("Not yet implemented") } override fun moveSouth(enabled: Boolean) { - TODO("Not yet implemented") } override fun moveWest(enabled: Boolean) { - TODO("Not yet implemented") } override fun moveEast(enabled: Boolean) { - TODO("Not yet implemented") } override fun coordinates(longitude: Angle, latitude: Angle, elevation: Distance) { @@ -200,5 +199,131 @@ class ASCOMMount( service.utcDate(device.number, dateTime.toInstant()) } + override fun snoop(devices: Iterable) {} + override fun handleMessage(message: INDIProtocol) {} + + override fun onConnected() { + processCapabilities() + processSiteCoordinates() + } + + override fun onDisconnected() {} + + override fun reset() { + super.reset() + + slewing = false + tracking = false + parking = false + parked = false + canAbort = false + canSync = false + canGoTo = false + canPark = false + canHome = false + slewRates = emptyList() + slewRate = null + mountType = MountType.EQ_GEM + trackModes = emptyList() + trackMode = TrackMode.SIDEREAL + pierSide = PierSide.NEITHER + guideRateWE = 0.0 + guideRateNS = 0.0 + rightAscension = 0.0 + declination = 0.0 + + canPulseGuide = false + pulseGuiding = false + + hasGPS = false + longitude = 0.0 + latitude = 0.0 + elevation = 0.0 + dateTime = OffsetDateTime.now()!! + } + + override fun close() { + super.close() + + if (canPulseGuide) { + canPulseGuide = false + sender.unregisterGuideOutput(this) + } + } + + override fun refresh(elapsedTimeInSeconds: Long) { + super.refresh(elapsedTimeInSeconds) + + processParked() + processSiteCoordinates() + } + + private fun processCapabilities() { + service.canFindHome(device.number).doRequest { + if (it.value) { + canHome = true + + sender.fireOnEventReceived(MountCanHomeChanged(this)) + } + } + + service.canPark(device.number).doRequest { + if (it.value) { + canPark = true + + sender.fireOnEventReceived(MountCanParkChanged(this)) + } + } + + service.canPulseGuide(device.number).doRequest { + if (it.value) { + canPulseGuide = true + sender.registerGuideOutput(this) + LOG.info("guide output attached: {}", name) + } + } + + service.canSync(device.number).doRequest { + if (it.value) { + canSync = true + + sender.fireOnEventReceived(MountCanSyncChanged(this)) + } + } + } + + private fun processParked() { + if (canPark) { + service.isAtPark(device.number).doRequest { + if (it.value != parked) { + parked = it.value + + sender.fireOnEventReceived(MountParkChanged(this)) + } + } + } + } + + private fun processSiteCoordinates() { + service.siteLongitude(device.number).doRequest { a -> + val lng = a.value.deg + + service.siteLatitude(device.number).doRequest { b -> + val lat = b.value.deg + + service.siteLatitude(device.number).doRequest { c -> + val elev = c.value.m + + if (lng != longitude || lat != latitude || elev != elevation) { + longitude = lng + latitude = lat + elevation = elev + + sender.fireOnEventReceived(MountGeographicCoordinateChanged(this)) + } + } + } + } + } } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMFilterWheel.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/wheels/ASCOMFilterWheel.kt similarity index 69% rename from nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMFilterWheel.kt rename to nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/wheels/ASCOMFilterWheel.kt index 2cef92aa5..846b76726 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMFilterWheel.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/wheels/ASCOMFilterWheel.kt @@ -1,14 +1,16 @@ -package nebulosa.alpaca.indi.device +package nebulosa.alpaca.indi.device.wheels import nebulosa.alpaca.api.AlpacaFilterWheelService import nebulosa.alpaca.api.ConfiguredDevice import nebulosa.alpaca.indi.client.AlpacaClient +import nebulosa.alpaca.indi.device.ASCOMDevice import nebulosa.indi.device.Device import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.indi.device.filterwheel.FilterWheelNamesChanged import nebulosa.indi.device.filterwheel.FilterWheelPositionChanged import nebulosa.indi.protocol.INDIProtocol -class ASCOMFilterWheel( +data class ASCOMFilterWheel( override val device: ConfiguredDevice, override val service: AlpacaFilterWheelService, override val sender: AlpacaClient, @@ -16,13 +18,16 @@ class ASCOMFilterWheel( @Volatile override var count = 0 private set - @Volatile override var position = -1 + @Volatile override var position = 0 private set @Volatile override var moving = false private set + @Volatile override var names: List = emptyList() + private set override fun onConnected() { processPosition() + processNames() } override fun onDisconnected() {} @@ -42,14 +47,15 @@ class ASCOMFilterWheel( } } - override fun names(names: Iterable) {} + override fun names(names: Iterable) { + this.names = names.toList() + } override fun snoop(devices: Iterable) {} override fun handleMessage(message: INDIProtocol) {} - private fun processMoving() { - } + private fun processMoving() {} private fun processPosition() { service.position(device.number).doRequest { @@ -61,4 +67,14 @@ class ASCOMFilterWheel( } } } + + private fun processNames() { + service.names(device.number).doRequest { + if (it.value != names) { + names = it.value + + sender.fireOnEventReceived(FilterWheelNamesChanged(this)) + } + } + } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt index 6847cc819..bd3baa130 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt @@ -2,16 +2,16 @@ package nebulosa.indi.client import nebulosa.indi.client.connection.INDIProccessConnection import nebulosa.indi.client.connection.INDISocketConnection -import nebulosa.indi.client.device.FilterWheelDevice -import nebulosa.indi.client.device.FocuserDevice import nebulosa.indi.client.device.GPSDevice import nebulosa.indi.client.device.INDIDeviceProtocolHandler import nebulosa.indi.client.device.cameras.AsiCamera -import nebulosa.indi.client.device.cameras.CameraDevice +import nebulosa.indi.client.device.cameras.INDICamera import nebulosa.indi.client.device.cameras.SVBonyCamera import nebulosa.indi.client.device.cameras.SimCamera +import nebulosa.indi.client.device.focusers.INDIFocuser +import nebulosa.indi.client.device.mounts.INDIMount import nebulosa.indi.client.device.mounts.IoptronV3Mount -import nebulosa.indi.client.device.mounts.MountDevice +import nebulosa.indi.client.device.wheels.INDIFilterWheel import nebulosa.indi.device.Device import nebulosa.indi.device.INDIDeviceProvider import nebulosa.indi.device.camera.Camera @@ -48,19 +48,19 @@ data class INDIClient(val connection: INDIConnection) : INDIDeviceProtocolHandle get() = connection.input override fun newCamera(message: INDIProtocol, executable: String): Camera { - return CAMERAS[executable]?.create(this, message.device) ?: CameraDevice(this, message.device) + return CAMERAS[executable]?.create(this, message.device) ?: INDICamera(this, message.device) } override fun newMount(message: INDIProtocol, executable: String): Mount { - return MOUNTS[executable]?.create(this, message.device) ?: MountDevice(this, message.device) + return MOUNTS[executable]?.create(this, message.device) ?: INDIMount(this, message.device) } override fun newFocuser(message: INDIProtocol): Focuser { - return FocuserDevice(this, message.device) + return INDIFocuser(this, message.device) } override fun newFilterWheel(message: INDIProtocol): FilterWheel { - return FilterWheelDevice(this, message.device) + return INDIFilterWheel(this, message.device) } override fun newGPS(message: INDIProtocol): GPS { diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt index 3a4c26fd7..7f37048fc 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt @@ -1,7 +1,7 @@ package nebulosa.indi.client.device import nebulosa.indi.client.INDIClient -import nebulosa.indi.client.device.cameras.CameraDevice +import nebulosa.indi.client.device.cameras.INDICamera import nebulosa.indi.device.* import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.dome.Dome @@ -217,7 +217,7 @@ internal abstract class INDIDevice : Device { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is CameraDevice) return false + if (other !is INDICamera) return false if (sender != other.sender) return false if (name != other.name) return false diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/AsiCamera.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/AsiCamera.kt index a429cbf00..a5834d09e 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/AsiCamera.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/AsiCamera.kt @@ -7,7 +7,7 @@ import nebulosa.indi.protocol.NumberVector internal class AsiCamera( provider: INDIClient, name: String, -) : CameraDevice(provider, name) { +) : INDICamera(provider, name) { override fun handleMessage(message: INDIProtocol) { when (message) { diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/CameraDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/INDICamera.kt similarity index 99% rename from nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/CameraDevice.kt rename to nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/INDICamera.kt index bd0a4accd..d3b5620a4 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/CameraDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/INDICamera.kt @@ -11,7 +11,7 @@ import nebulosa.io.Base64InputStream import nebulosa.log.loggerFor import java.time.Duration -internal open class CameraDevice( +internal open class INDICamera( override val sender: INDIClient, override val name: String, ) : INDIDevice(), Camera { @@ -445,6 +445,6 @@ internal open class CameraDevice( companion object { @JvmStatic private val COMPRESSION_FORMATS = arrayOf(".fz", ".gz") - @JvmStatic private val LOG = loggerFor() + @JvmStatic private val LOG = loggerFor() } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SVBonyCamera.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SVBonyCamera.kt index 0edd4b47f..f8e2f8f45 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SVBonyCamera.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SVBonyCamera.kt @@ -7,7 +7,7 @@ import nebulosa.indi.protocol.NumberVector internal class SVBonyCamera( provider: INDIClient, name: String, -) : CameraDevice(provider, name) { +) : INDICamera(provider, name) { @Volatile private var legacyProperties = false diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SimCamera.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SimCamera.kt index fe647beed..4a674e898 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SimCamera.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SimCamera.kt @@ -7,7 +7,7 @@ import nebulosa.indi.protocol.NumberVector internal class SimCamera( provider: INDIClient, name: String, -) : CameraDevice(provider, name) { +) : INDICamera(provider, name) { override fun handleMessage(message: INDIProtocol) { when (message) { diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/focusers/INDIFocuser.kt similarity index 96% rename from nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt rename to nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/focusers/INDIFocuser.kt index ace477de9..3dfa9d22e 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FocuserDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/focusers/INDIFocuser.kt @@ -1,13 +1,14 @@ -package nebulosa.indi.client.device +package nebulosa.indi.client.device.focusers import nebulosa.indi.client.INDIClient +import nebulosa.indi.client.device.INDIDevice import nebulosa.indi.device.firstOnSwitch import nebulosa.indi.device.focuser.* import nebulosa.indi.device.thermometer.ThermometerAttached import nebulosa.indi.device.thermometer.ThermometerDetached import nebulosa.indi.protocol.* -internal open class FocuserDevice( +internal open class INDIFocuser( override val sender: INDIClient, override val name: String, ) : INDIDevice(), Focuser { @@ -24,7 +25,7 @@ internal open class FocuserDevice( private set @Volatile final override var canReverse = false private set - @Volatile final override var reverse = false + @Volatile final override var reversed = false private set @Volatile final override var canSync = false private set @@ -56,7 +57,7 @@ internal open class FocuserDevice( sender.fireOnEventReceived(FocuserCanReverseChanged(this)) } - reverse = message.firstOnSwitch().name == "INDI_ENABLED" + reversed = message.firstOnSwitch().name == "INDI_ENABLED" sender.fireOnEventReceived(FocuserReverseChanged(this)) } @@ -198,7 +199,7 @@ internal open class FocuserDevice( override fun toString(): String { return "Focuser(name=$name, moving=$moving, position=$position," + " canAbsoluteMove=$canAbsoluteMove, canRelativeMove=$canRelativeMove," + - " canAbort=$canAbort, canReverse=$canReverse, reverse=$reverse," + + " canAbort=$canAbort, canReverse=$canReverse, reversed=$reversed," + " canSync=$canSync, hasBacklash=$hasBacklash," + " maxPosition=$maxPosition, hasThermometer=$hasThermometer," + " temperature=$temperature)" diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/MountDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/INDIMount.kt similarity index 99% rename from nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/MountDevice.kt rename to nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/INDIMount.kt index 4011824e3..84fe447bf 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/MountDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/INDIMount.kt @@ -15,7 +15,7 @@ import java.time.Duration import java.time.OffsetDateTime import java.time.ZoneOffset -internal open class MountDevice( +internal open class INDIMount( override val sender: INDIClient, override val name: String, ) : INDIDevice(), Mount { @@ -365,6 +365,6 @@ internal open class MountDevice( companion object { - @JvmStatic private val LOG = loggerFor() + @JvmStatic private val LOG = loggerFor() } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/IoptronV3Mount.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/IoptronV3Mount.kt index 63777d120..4ef2fc209 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/IoptronV3Mount.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/IoptronV3Mount.kt @@ -8,7 +8,7 @@ import nebulosa.indi.protocol.SwitchVector internal class IoptronV3Mount( provider: INDIClient, name: String, -) : MountDevice(provider, name) { +) : INDIMount(provider, name) { override fun handleMessage(message: INDIProtocol) { when (message) { diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/wheels/INDIFilterWheel.kt similarity index 73% rename from nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt rename to nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/wheels/INDIFilterWheel.kt index e04510266..cb0cd3658 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/FilterWheelDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/wheels/INDIFilterWheel.kt @@ -1,24 +1,24 @@ -package nebulosa.indi.client.device +package nebulosa.indi.client.device.wheels import nebulosa.indi.client.INDIClient +import nebulosa.indi.client.device.INDIDevice import nebulosa.indi.device.filterwheel.* -import nebulosa.indi.protocol.DefNumberVector -import nebulosa.indi.protocol.INDIProtocol -import nebulosa.indi.protocol.NumberVector -import nebulosa.indi.protocol.PropertyState +import nebulosa.indi.protocol.* -internal open class FilterWheelDevice( +internal open class INDIFilterWheel( override val sender: INDIClient, override val name: String, ) : INDIDevice(), FilterWheel { @Volatile final override var count = 0 private set - @Volatile final override var position = -1 + @Volatile final override var position = 0 private set @Volatile final override var moving = false private set + final override val names = ArrayList(12) + override fun handleMessage(message: INDIProtocol) { when (message) { is NumberVector<*> -> { @@ -51,6 +51,23 @@ internal open class FilterWheelDevice( } } } + is TextVector<*> -> { + when (message.name) { + "FILTER_NAME" -> { + names.clear() + + repeat(16) { + val key = "FILTER_SLOT_NAME_${it + 1}" + + if (key in message) { + names.add(message[key]!!.value) + } + } + + sender.fireOnEventReceived(FilterWheelNamesChanged(this)) + } + } + } else -> Unit } diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheel.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheel.kt index 45025f2da..ff8eee139 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheel.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheel.kt @@ -10,6 +10,8 @@ interface FilterWheel : Device { val moving: Boolean + val names: List + fun moveTo(position: Int) fun names(names: Iterable) diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheelNamesChanged.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheelNamesChanged.kt new file mode 100644 index 000000000..941e480a3 --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheelNamesChanged.kt @@ -0,0 +1,5 @@ +package nebulosa.indi.device.filterwheel + +import nebulosa.indi.device.PropertyChangedEvent + +data class FilterWheelNamesChanged(override val device: FilterWheel) : FilterWheelEvent, PropertyChangedEvent diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/focuser/Focuser.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/focuser/Focuser.kt index 12d4b4787..a18b1e2c9 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/focuser/Focuser.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/focuser/Focuser.kt @@ -17,7 +17,7 @@ interface Focuser : Device, Thermometer { val canReverse: Boolean - val reverse: Boolean + val reversed: Boolean val canSync: Boolean From 264f1009b20ad51e9c86d1d09e17e0781b6bb19e Mon Sep 17 00:00:00 2001 From: tiagohm Date: Tue, 20 Feb 2024 23:36:23 -0300 Subject: [PATCH 40/87] [api]: Add support to ASCOM Alpaca API --- .../alpaca/api/AlpacaCameraService.kt | 2 +- .../alpaca/api/AlpacaGuideOutputService.kt | 2 +- .../alpaca/api/AlpacaTelescopeService.kt | 2 +- .../alpaca/indi/device/mounts/ASCOMMount.kt | 179 ++++++++++++++++-- .../MountEquatorialJ2000CoordinatesChanged.kt | 5 - .../main/kotlin/nebulosa/time/CurrentTime.kt | 4 +- 6 files changed, 169 insertions(+), 25 deletions(-) delete mode 100644 nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/mount/MountEquatorialJ2000CoordinatesChanged.kt diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt index 2a2eff8f2..188520472 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaCameraService.kt @@ -232,7 +232,7 @@ interface AlpacaCameraService : AlpacaGuideOutputService { override fun pulseGuide( @Path("id") id: Int, @Field("Direction") direction: PulseGuideDirection, - @Field("Duration") durationMs: Long + @Field("Duration") durationInMilliseconds: Long ): Call @FormUrlEncoded diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaGuideOutputService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaGuideOutputService.kt index 6d37e82a6..17531e06c 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaGuideOutputService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaGuideOutputService.kt @@ -8,5 +8,5 @@ interface AlpacaGuideOutputService : AlpacaDeviceService { fun isPulseGuiding(id: Int): Call - fun pulseGuide(id: Int, direction: PulseGuideDirection, durationMs: Long): Call + fun pulseGuide(id: Int, direction: PulseGuideDirection, durationInMilliseconds: Long): Call } diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt index f66cddcf1..74e4ef4a4 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt @@ -241,7 +241,7 @@ interface AlpacaTelescopeService : AlpacaGuideOutputService { override fun pulseGuide( @Path("id") id: Int, @Field("Direction") direction: PulseGuideDirection, - @Field("Duration") durationMs: Long + @Field("Duration") durationInMilliseconds: Long ): Call @FormUrlEncoded diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt index b8f6e58f0..d87ded2f8 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt @@ -1,20 +1,19 @@ package nebulosa.alpaca.indi.device.mounts -import nebulosa.alpaca.api.AlpacaTelescopeService -import nebulosa.alpaca.api.ConfiguredDevice -import nebulosa.alpaca.api.DriveRate -import nebulosa.alpaca.api.PulseGuideDirection +import nebulosa.alpaca.api.* import nebulosa.alpaca.indi.client.AlpacaClient import nebulosa.alpaca.indi.device.ASCOMDevice import nebulosa.indi.device.Device import nebulosa.indi.device.guide.GuideOutputPulsingChanged import nebulosa.indi.device.mount.* +import nebulosa.indi.device.mount.PierSide import nebulosa.indi.protocol.INDIProtocol import nebulosa.math.* import nebulosa.nova.position.ICRF import nebulosa.time.CurrentTime import java.time.Duration import java.time.OffsetDateTime +import java.time.ZoneOffset data class ASCOMMount( override val device: ConfiguredDevice, @@ -77,6 +76,10 @@ data class ASCOMMount( @Volatile override var dateTime = OffsetDateTime.now()!! private set + private val axisRates = HashMap(4) + @Volatile private var axisRate: AxisRate? = null + @Volatile private var equatorialSystem = EquatorialCoordinateType.J2000 + override fun park() { if (canPark) { service.park(device.number).doRequest() @@ -124,26 +127,52 @@ data class ASCOMMount( override fun sync(ra: Angle, dec: Angle) { if (canSync) { - service.syncToCoordinates(device.number, ra.toHours, dec.toDegrees).doRequest() + if (equatorialSystem != EquatorialCoordinateType.J2000) { + service.syncToCoordinates(device.number, ra.toHours, dec.toDegrees).doRequest() + } else { + // J2000 -> JNOW. + with(ICRF.equatorial(ra, dec).equatorialAtDate()) { + service.syncToCoordinates(device.number, longitude.normalized.toHours, latitude.toDegrees).doRequest() + } + } } } override fun syncJ2000(ra: Angle, dec: Angle) { if (canSync) { - with(ICRF.equatorial(ra, dec, epoch = CurrentTime).equatorial()) { - sync(longitude.normalized, latitude) + if (equatorialSystem == EquatorialCoordinateType.J2000) { + service.syncToCoordinates(device.number, ra.toHours, dec.toDegrees).doRequest() + } else { + // JNOW -> J2000. + with(ICRF.equatorial(ra, dec, epoch = CurrentTime).equatorial()) { + service.syncToCoordinates(device.number, longitude.normalized.toHours, latitude.toDegrees).doRequest() + } } } } override fun slewTo(ra: Angle, dec: Angle) { - service.slewToCoordinates(device.number, ra.toHours, dec.toDegrees).doRequest() + service.tracking(device.number, true) + + if (equatorialSystem != EquatorialCoordinateType.J2000) { + service.slewToCoordinates(device.number, ra.toHours, dec.toDegrees).doRequest() + } else { + // J2000 -> JNOW. + with(ICRF.equatorial(ra, dec).equatorialAtDate()) { + service.slewToCoordinates(device.number, longitude.normalized.toHours, latitude.toDegrees).doRequest() + } + } } override fun slewToJ2000(ra: Angle, dec: Angle) { - if (canSync) { + service.tracking(device.number, true) + + if (equatorialSystem == EquatorialCoordinateType.J2000) { + service.slewToCoordinates(device.number, ra.toHours, dec.toDegrees).doRequest() + } else { + // JNOW -> J2000. with(ICRF.equatorial(ra, dec, epoch = CurrentTime).equatorial()) { - slewTo(longitude.normalized, latitude) + service.slewToCoordinates(device.number, longitude.normalized.toHours, latitude.toDegrees).doRequest() } } } @@ -175,18 +204,34 @@ data class ASCOMMount( } override fun slewRate(rate: SlewRate) { + axisRate = axisRates[rate.name]?.takeIf { it !== axisRate } ?: return + sender.fireOnEventReceived(MountSlewRateChanged(this)) + } + + private fun moveAxis(axisType: AxisType, negative: Boolean, enabled: Boolean) { + val rate = axisRate?.maximum ?: return + + service.moveAxis(device.number, axisType, 0.0).doRequest() + + if (enabled) { + service.moveAxis(device.number, axisType, if (negative) -rate else rate).doRequest() + } } override fun moveNorth(enabled: Boolean) { + moveAxis(AxisType.SECONDARY, false, enabled) } override fun moveSouth(enabled: Boolean) { + moveAxis(AxisType.SECONDARY, true, enabled) } override fun moveWest(enabled: Boolean) { + moveAxis(AxisType.PRIMARY, true, enabled) } override fun moveEast(enabled: Boolean) { + moveAxis(AxisType.PRIMARY, false, enabled) } override fun coordinates(longitude: Angle, latitude: Angle, elevation: Distance) { @@ -205,7 +250,13 @@ data class ASCOMMount( override fun onConnected() { processCapabilities() + processGuideRates() processSiteCoordinates() + processDateTime() + + equatorialSystem = service.equatorialSystem(device.number).doRequest()?.value ?: equatorialSystem + + LOG.info("The mount {} uses {} equatorial system", name, equatorialSystem) } override fun onDisconnected() {} @@ -241,6 +292,9 @@ data class ASCOMMount( latitude = 0.0 elevation = 0.0 dateTime = OffsetDateTime.now()!! + + axisRates.clear() + axisRate = null } override fun close() { @@ -255,15 +309,18 @@ data class ASCOMMount( override fun refresh(elapsedTimeInSeconds: Long) { super.refresh(elapsedTimeInSeconds) + processTracking() + processSlewing() processParked() + processEquatorialCoordinates() processSiteCoordinates() + processTrackMode() } private fun processCapabilities() { service.canFindHome(device.number).doRequest { if (it.value) { canHome = true - sender.fireOnEventReceived(MountCanHomeChanged(this)) } } @@ -271,7 +328,6 @@ data class ASCOMMount( service.canPark(device.number).doRequest { if (it.value) { canPark = true - sender.fireOnEventReceived(MountCanParkChanged(this)) } } @@ -287,10 +343,52 @@ data class ASCOMMount( service.canSync(device.number).doRequest { if (it.value) { canSync = true - sender.fireOnEventReceived(MountCanSyncChanged(this)) } } + + service.trackingRates(device.number).doRequest { + trackModes = it.value.map { m -> TrackMode.valueOf(m.name) } + sender.fireOnEventReceived(MountTrackModesChanged(this)) + processTrackMode() + } + + service.axisRates(device.number).doRequest { + val rates = ArrayList(it.value.size) + + axisRates.clear() + + for (i in it.value.indices) { + val rate = it.value[i] + val name = "RATE_$i" + axisRates[name] = rate + rates.add(SlewRate(name, "%f.1f deg/s".format(rate.maximum))) + } + + axisRate = it.value.firstOrNull() + + if (axisRate != null) { + sender.fireOnEventReceived(MountSlewRateChanged(this)) + } + } + } + + private fun processTracking() { + service.isTracking(device.number).doRequest { + if (it.value != tracking) { + tracking = it.value + sender.fireOnEventReceived(MountTrackingChanged(this)) + } + } + } + + private fun processSlewing() { + service.isSlewing(device.number).doRequest { + if (it.value != slewing) { + slewing = it.value + sender.fireOnEventReceived(MountSlewingChanged(this)) + } + } } private fun processParked() { @@ -298,7 +396,6 @@ data class ASCOMMount( service.isAtPark(device.number).doRequest { if (it.value != parked) { parked = it.value - sender.fireOnEventReceived(MountParkChanged(this)) } } @@ -312,7 +409,7 @@ data class ASCOMMount( service.siteLatitude(device.number).doRequest { b -> val lat = b.value.deg - service.siteLatitude(device.number).doRequest { c -> + service.siteElevation(device.number).doRequest { c -> val elev = c.value.m if (lng != longitude || lat != latitude || elev != elevation) { @@ -326,4 +423,56 @@ data class ASCOMMount( } } } + + private fun processDateTime() { + service.utcDate(device.number).doRequest { + dateTime = it.value.atOffset(ZoneOffset.systemDefault().rules.getOffset(it.value)) + sender.fireOnEventReceived(MountTimeChanged(this)) + } + } + + private fun processTrackMode() { + service.trackingRate(device.number).doRequest { + if (it.value.name != trackMode.name) { + trackMode = TrackMode.valueOf(it.value.name) + sender.fireOnEventReceived(MountTrackModeChanged(this)) + } + } + } + + private fun processGuideRates() { + service.guideRateRightAscension(device.number).doRequest { ra -> + // TODO: deg/s is the same for INDI? + guideRateWE = ra.value + + service.guideRateDeclination(device.number).doRequest { de -> + guideRateNS = de.value + } + } + } + + private fun processEquatorialCoordinates() { + service.rightAscension(device.number).doRequest { a -> + var ra = a.value.hours + + service.declination(device.number).doRequest { b -> + var dec = b.value.deg + + // J2000 -> JNOW. + if (equatorialSystem == EquatorialCoordinateType.J2000) { + with(ICRF.equatorial(ra, dec).equatorialAtDate()) { + ra = longitude.normalized + dec = latitude + } + } + + if (ra != rightAscension || dec != declination) { + rightAscension = ra + declination = dec + + sender.fireOnEventReceived(MountEquatorialCoordinatesChanged(this)) + } + } + } + } } diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/mount/MountEquatorialJ2000CoordinatesChanged.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/mount/MountEquatorialJ2000CoordinatesChanged.kt deleted file mode 100644 index 930eecb31..000000000 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/mount/MountEquatorialJ2000CoordinatesChanged.kt +++ /dev/null @@ -1,5 +0,0 @@ -package nebulosa.indi.device.mount - -import nebulosa.indi.device.PropertyChangedEvent - -data class MountEquatorialJ2000CoordinatesChanged(override val device: Mount) : MountEvent, PropertyChangedEvent diff --git a/nebulosa-time/src/main/kotlin/nebulosa/time/CurrentTime.kt b/nebulosa-time/src/main/kotlin/nebulosa/time/CurrentTime.kt index 206e6bbaa..42e9ee11a 100644 --- a/nebulosa-time/src/main/kotlin/nebulosa/time/CurrentTime.kt +++ b/nebulosa-time/src/main/kotlin/nebulosa/time/CurrentTime.kt @@ -4,7 +4,7 @@ import nebulosa.common.time.Stopwatch object CurrentTime : InstantOfTime() { - const val ELAPSED_INTERVAL = 1000L + @JvmField @Volatile var ELAPSED_INTERVAL = 5L private val stopwatch = Stopwatch() @@ -15,7 +15,7 @@ object CurrentTime : InstantOfTime() { private var time = UTC.now() get() { synchronized(stopwatch) { - if (stopwatch.elapsedMilliseconds >= ELAPSED_INTERVAL) { + if (stopwatch.elapsedSeconds >= ELAPSED_INTERVAL) { stopwatch.reset() field = UTC.now() } From d8d3040860e0d1b23b8bce5391a5cfaab555a324 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Tue, 20 Feb 2024 23:58:25 -0300 Subject: [PATCH 41/87] [desktop]: Update screenshots --- desktop/README.md | 2 +- desktop/alignment.darv.png | Bin 15201 -> 0 bytes desktop/alignment.png | Bin 0 -> 23214 bytes desktop/home.png | Bin 38006 -> 37346 bytes 4 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 desktop/alignment.darv.png create mode 100644 desktop/alignment.png diff --git a/desktop/README.md b/desktop/README.md index 113adf9fb..a31cbde6e 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -40,7 +40,7 @@ The complete integrated solution for all of your astronomical imaging needs. ## Alignment -![](alignment.darv.png) +![](alignment.png) ## Flat Wizard diff --git a/desktop/alignment.darv.png b/desktop/alignment.darv.png deleted file mode 100644 index ad58ed7221ff2651caee835ce52544f6b68307a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15201 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 diff --git a/desktop/alignment.png b/desktop/alignment.png new file mode 100644 index 0000000000000000000000000000000000000000..69d73ce8cd6676f547cbf6e98bcd31fbd474cee8 GIT binary patch literal 23214 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 V?h9diqL(zu+r!uW+NRKyKLJ|aTVVhI literal 0 HcmV?d00001 diff --git a/desktop/home.png b/desktop/home.png index 1ecd054fd090030bd58f9758204308ee11a30a6e..87baddaf32c01f9db329ee82e71f5be36c149420 100644 GIT binary patch literal 37346 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 literal 38006 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 From 4c08096f410be973d8d70df6e8fd6ccc173b44c1 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Wed, 21 Feb 2024 00:01:27 -0300 Subject: [PATCH 42/87] [desktop]: Update screenshots --- desktop/flat-wizard.png | Bin 18390 -> 19634 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/desktop/flat-wizard.png b/desktop/flat-wizard.png index bb626170439464389a41c822ac835b83e27d8bf1..46e447b759a8936288e4e13773bc37b30945e0e1 100644 GIT binary patch literal 19634 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 ih?B29PurDM^xq)R~h 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 Zygw_tzufW~{+~N2@fT9UInOme{9iVO`z!ze From b6a5f71f9ff41d5b8fcb4a2e6486842da6650c1a Mon Sep 17 00:00:00 2001 From: tiagohm Date: Fri, 23 Feb 2024 21:03:24 -0300 Subject: [PATCH 43/87] [desktop]: Improve camera exposure component layout --- desktop/app/main.ts | 2 +- desktop/src/app/app.component.ts | 2 +- desktop/src/app/camera/camera.component.html | 4 +- .../camera-exposure.component.html | 49 ++++++++++--------- .../camera-exposure.component.scss | 18 +------ .../camera-exposure.component.ts | 7 +++ desktop/src/shared/pipes/exposureTime.pipe.ts | 3 +- 7 files changed, 42 insertions(+), 43 deletions(-) diff --git a/desktop/app/main.ts b/desktop/app/main.ts index 2bd2904fb..908a9cf2a 100644 --- a/desktop/app/main.ts +++ b/desktop/app/main.ts @@ -419,7 +419,7 @@ try { if (!window || (!serve && window.isResizable())) return false const size = window.getSize() - window.setSize(size[0], Math.max(size[1], data)) + window.setSize(size[0], Math.max(0, data)) console.info('window resized', size[0], data) return true diff --git a/desktop/src/app/app.component.ts b/desktop/src/app/app.component.ts index 451d2609d..51f9ac9e9 100644 --- a/desktop/src/app/app.component.ts +++ b/desktop/src/app/app.component.ts @@ -54,7 +54,7 @@ export class AppComponent implements AfterViewInit { const size = document.getElementsByTagName('app-root')[0]?.getBoundingClientRect()?.height if (size) { - this.electron.send('WINDOW.RESIZE', Math.ceil(size)) + this.electron.send('WINDOW.RESIZE', Math.floor(size)) } }, 1000) } diff --git a/desktop/src/app/camera/camera.component.html b/desktop/src/app/camera/camera.component.html index 7c2b38f53..5edb6afc9 100644 --- a/desktop/src/app/camera/camera.component.html +++ b/desktop/src/app/camera/camera.component.html @@ -18,7 +18,7 @@
-
+
+ class="flex flex-1 align-items-center gap-2 bg-gray-800 border-round px-3 text-overflow-scroll"> - +
+ + {{ (state ?? 'IDLE') | enum | lowercase }} + + + - {{ exposure.count }} of {{ capture.amount }} + {{ exposure.count }} / {{ capture.amount }} + + + {{ capture.progress * 100 | number:'1.1-1' }} - + @if (capture.looping) { + {{ capture.elapsedTime | exposureTime }} - @if (!capture.looping) { - + } @else if(showRemainingTime) { + {{ capture.remainingTime | exposureTime }} - - {{ capture.progress * 100 | number:'1.1-1' }} + } @else { + + {{ capture.elapsedTime | exposureTime }} } - - - - - {{ (state ?? 'IDLE') | enum | lowercase }} - - - + + @if (state === 'EXPOSURING') { + {{ exposure.remainingTime | exposureTime }} {{ exposure.progress * 100 | number:'1.1-1' }} - - + } + + @if (state === 'WAITING') { {{ wait.remainingTime | exposureTime }} @@ -39,5 +43,6 @@ {{ wait.progress * 100 | number:'1.1-1' }} + } - \ No newline at end of file +
\ No newline at end of file diff --git a/desktop/src/shared/components/camera-exposure/camera-exposure.component.scss b/desktop/src/shared/components/camera-exposure/camera-exposure.component.scss index 35a709bcc..595ee8c38 100644 --- a/desktop/src/shared/components/camera-exposure/camera-exposure.component.scss +++ b/desktop/src/shared/components/camera-exposure/camera-exposure.component.scss @@ -1,12 +1,10 @@ :host { min-height: 29px; - display: flex; - flex-direction: row; - gap: 4px; - align-items: end; + width: 100%; .state { padding: 1px 6px; + height: 13px; &.percentage { min-width: 50px; @@ -25,18 +23,6 @@ } } - .state-group { - .state:first-child { - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; - } - - .state:last-child { - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; - } - } - .mdi-information::before { font-size: 0.9rem !important; } 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 081229f4e..f38932990 100644 --- a/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts +++ b/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts @@ -11,6 +11,9 @@ export class CameraExposureComponent { @Input() state?: CameraCaptureState = 'IDLE' + @Input() + showRemainingTime: boolean = true + @Input() readonly exposure = Object.assign({}, EMPTY_CAMERA_EXPOSURE_INFO) @@ -57,4 +60,8 @@ export class CameraExposureComponent { Object.assign(this.capture, EMPTY_CAMERA_CAPTURE_INFO) Object.assign(this.wait, EMPTY_CAMERA_WAIT_INFO) } + + toggleRemainingTime() { + this.showRemainingTime = !this.showRemainingTime + } } \ No newline at end of file diff --git a/desktop/src/shared/pipes/exposureTime.pipe.ts b/desktop/src/shared/pipes/exposureTime.pipe.ts index 8ef414d2a..0a48d1b86 100644 --- a/desktop/src/shared/pipes/exposureTime.pipe.ts +++ b/desktop/src/shared/pipes/exposureTime.pipe.ts @@ -51,7 +51,8 @@ function minutes(value: number) { } function seconds(value: number) { - return format(value, [1000000, 1000], [secondFormatter, millisecondFormatter]) + return `${TWO_DIGITS_FORMATTER.format(value / 1000000)}s` + // return format(value, [1000000, 1000], [secondFormatter, millisecondFormatter]) } function milliseconds(value: number) { From c6b4c4f4bbf3976d93c1883fead3b05a98790877 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Fri, 23 Feb 2024 21:24:25 -0300 Subject: [PATCH 44/87] [desktop]: Fix Home window auto resize --- desktop/src/app/home/home.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/app/home/home.component.html b/desktop/src/app/home/home.component.html index 9e20cab1b..05e1301ee 100644 --- a/desktop/src/app/home/home.component.html +++ b/desktop/src/app/home/home.component.html @@ -35,7 +35,7 @@
-
+
From c0309c67c902e6bfffb51451ddb3b4bdad20022c Mon Sep 17 00:00:00 2001 From: tiagohm Date: Fri, 23 Feb 2024 21:40:10 -0300 Subject: [PATCH 45/87] [desktop]: Fix INDI panel control property changes --- desktop/src/app/indi/indi.component.ts | 42 +++++++++++++++++--------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/desktop/src/app/indi/indi.component.ts b/desktop/src/app/indi/indi.component.ts index a6867b318..7fb7d20ae 100644 --- a/desktop/src/app/indi/indi.component.ts +++ b/desktop/src/app/indi/indi.component.ts @@ -33,23 +33,27 @@ export class INDIComponent implements AfterViewInit, OnDestroy { app.title = 'INDI' electron.on('DEVICE.PROPERTY_CHANGED', event => { - ngZone.run(() => { - this.addOrUpdateProperty(event.property!) - this.updateGroups() - }) - }) - - electron.on('DEVICE.PROPERTY_DELETED', event => { - const index = this.properties.findIndex((e) => e.name === event.property!.name) - - if (index >= 0) { + if (this.device?.id === event.device.id) { ngZone.run(() => { - this.properties.splice(index, 1) + this.addOrUpdateProperty(event.property!) this.updateGroups() }) } }) + electron.on('DEVICE.PROPERTY_DELETED', event => { + if (this.device?.id === event.device.id) { + const index = this.properties.findIndex((e) => e.name === event.property!.name) + + if (index >= 0) { + ngZone.run(() => { + this.properties.splice(index, 1) + this.updateGroups() + }) + } + } + }) + electron.on('DEVICE.MESSAGE_RECEIVED', event => { if (this.device && event.device?.id === this.device.id) { ngZone.run(() => { @@ -76,6 +80,10 @@ export class INDIComponent implements AfterViewInit, OnDestroy { ].sort(deviceComparator) this.device = this.devices[0] + + if (this.device) { + this.deviceChanged(this.device) + } } @HostListener('window:unload') @@ -116,10 +124,14 @@ export class INDIComponent implements AfterViewInit, OnDestroy { let groupsChanged = false if (this.groups.length === groups.size) { - let index = 0 - - for (const item of groups) { - if (this.groups[index++].label !== item) { + for (const group of groups) { + if (!this.groups.find(e => e.label === group)) { + groupsChanged = true + break + } + } + for (const group of this.groups) { + if (!groups.has(group.label!)) { groupsChanged = true break } From e059b19519ed6594927a05fe2c4f221cc6bf230a Mon Sep 17 00:00:00 2001 From: tiagohm Date: Fri, 23 Feb 2024 21:54:33 -0300 Subject: [PATCH 46/87] [desktop]: Fix INDI Log Listbox don't was updated because of its OnPush strategy * https://blog.angular-university.io/onpush-change-detection-how-it-works/ --- desktop/src/app/indi/indi.component.html | 2 +- desktop/src/app/indi/indi.component.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/desktop/src/app/indi/indi.component.html b/desktop/src/app/indi/indi.component.html index 69fc7616b..ceeea7a75 100644 --- a/desktop/src/app/indi/indi.component.html +++ b/desktop/src/app/indi/indi.component.html @@ -19,7 +19,7 @@
- +
diff --git a/desktop/src/app/indi/indi.component.ts b/desktop/src/app/indi/indi.component.ts index 7fb7d20ae..765a7d381 100644 --- a/desktop/src/app/indi/indi.component.ts +++ b/desktop/src/app/indi/indi.component.ts @@ -1,6 +1,7 @@ -import { AfterViewInit, Component, HostListener, NgZone, OnDestroy } from '@angular/core' +import { AfterViewInit, Component, HostListener, NgZone, OnDestroy, ViewChild } from '@angular/core' import { ActivatedRoute } from '@angular/router' import { MenuItem } from 'primeng/api' +import { Listbox } from 'primeng/listbox' import { ApiService } from '../../shared/services/api.service' import { ElectronService } from '../../shared/services/electron.service' import { Device, INDIProperty, INDIPropertyItem, INDISendProperty } from '../../shared/types/device.types' @@ -23,6 +24,9 @@ export class INDIComponent implements AfterViewInit, OnDestroy { showLog = false messages: string[] = [] + @ViewChild('listbox') + readonly messageListbox!: Listbox + constructor( app: AppComponent, private route: ActivatedRoute, @@ -58,6 +62,7 @@ export class INDIComponent implements AfterViewInit, OnDestroy { if (this.device && event.device?.id === this.device.id) { ngZone.run(() => { this.messages.splice(0, 0, event.message!) + this.messageListbox.cd.markForCheck() }) } }) From 5ec637e84260de7b339d1a0e4da67982fdaf25fb Mon Sep 17 00:00:00 2001 From: tiagohm Date: Fri, 23 Feb 2024 23:47:47 -0300 Subject: [PATCH 47/87] [api][desktop]: Improve server-side connection close handling --- .../connection/ConnectionClosedWithClient.kt | 8 +++++ .../api/connection/ConnectionEventHandler.kt | 4 +-- .../api/connection/ConnectionService.kt | 16 ++++++++- desktop/src/app/home/home.component.ts | 8 +++++ .../src/shared/services/electron.service.ts | 2 ++ desktop/src/shared/types/home.types.ts | 4 +++ nebulosa-alpaca-indi/build.gradle.kts | 1 + .../alpaca/indi/device/ASCOMDevice.kt | 2 ++ nebulosa-indi-client/build.gradle.kts | 1 + .../kotlin/nebulosa/indi/client/INDIClient.kt | 7 +++- .../connection/INDIProccessConnection.kt | 2 +- .../client/connection/INDISocketConnection.kt | 33 +++++-------------- .../device/INDIDeviceProtocolHandler.kt | 26 ++++++++++----- nebulosa-indi-connection/build.gradle.kts | 17 ++++++++++ .../indi/connection/ConnectionClosed.kt | 10 ++++++ .../nebulosa/indi/device/ConnectionEvent.kt | 3 -- .../nebulosa/indi/device/DeviceConnected.kt | 2 +- .../indi/device/DeviceConnectionEvent.kt | 3 ++ .../indi/device/DeviceDisconnected.kt | 2 +- .../parser/CloseConnectionListener.kt | 6 ++++ .../protocol/parser/INDIProtocolReader.kt | 27 +++++++++++++-- settings.gradle.kts | 1 + 22 files changed, 139 insertions(+), 46 deletions(-) create mode 100644 api/src/main/kotlin/nebulosa/api/connection/ConnectionClosedWithClient.kt create mode 100644 nebulosa-indi-connection/build.gradle.kts create mode 100644 nebulosa-indi-connection/src/main/kotlin/nebulosa/indi/connection/ConnectionClosed.kt delete mode 100644 nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/ConnectionEvent.kt create mode 100644 nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceConnectionEvent.kt create mode 100644 nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/CloseConnectionListener.kt diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionClosedWithClient.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionClosedWithClient.kt new file mode 100644 index 000000000..ed98c0613 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionClosedWithClient.kt @@ -0,0 +1,8 @@ +package nebulosa.api.connection + +import nebulosa.api.messages.MessageEvent + +data class ConnectionClosedWithClient(@JvmField val id: String) : MessageEvent { + + override val eventName = "CONNECTION.CLOSED" +} diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionEventHandler.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionEventHandler.kt index 1719d5d97..a761977ef 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionEventHandler.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionEventHandler.kt @@ -5,7 +5,7 @@ import nebulosa.api.focusers.FocuserEventHandler import nebulosa.api.guiding.GuideOutputEventHandler import nebulosa.api.mounts.MountEventHandler import nebulosa.api.wheels.WheelEventHandler -import nebulosa.indi.device.ConnectionEvent +import nebulosa.indi.device.DeviceConnectionEvent import nebulosa.indi.device.DeviceEvent import nebulosa.indi.device.DeviceEventHandler import nebulosa.indi.device.camera.Camera @@ -26,7 +26,7 @@ class ConnectionEventHandler( @Suppress("CascadeIf") override fun onEventReceived(event: DeviceEvent<*>) { - if (event is ConnectionEvent) { + if (event is DeviceConnectionEvent) { val device = event.device ?: return if (device is Camera) cameraEventHandler.sendUpdate(device) diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt index b0c72ab8a..a54f20eb9 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt @@ -1,9 +1,13 @@ package nebulosa.api.connection import nebulosa.alpaca.indi.client.AlpacaClient +import nebulosa.api.messages.MessageService import nebulosa.indi.client.INDIClient import nebulosa.indi.client.connection.INDISocketConnection +import nebulosa.indi.connection.ConnectionClosed import nebulosa.indi.device.Device +import nebulosa.indi.device.DeviceEvent +import nebulosa.indi.device.DeviceEventHandler import nebulosa.indi.device.INDIDeviceProvider import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.filterwheel.FilterWheel @@ -25,7 +29,8 @@ class ConnectionService( private val eventBus: EventBus, private val connectionEventHandler: ConnectionEventHandler, private val alpacaHttpClient: OkHttpClient, -) : Closeable { + private val messageService: MessageService, +) : DeviceEventHandler, Closeable { private val providers = LinkedHashMap() @@ -58,6 +63,7 @@ class ConnectionService( val client = INDIClient(host, port) client.registerDeviceEventHandler(eventBus::post) client.registerDeviceEventHandler(connectionEventHandler) + client.registerDeviceEventHandler(this) client.start() client } @@ -91,6 +97,14 @@ class ConnectionService( providers.clear() } + override fun onEventReceived(event: DeviceEvent<*>) { + if (event is ConnectionClosed) { + LOG.info("client connection was closed. id={}", event.provider.id) + providers.remove(event.provider.id) + messageService.sendMessage(ConnectionClosedWithClient(event.provider.id)) + } + } + override fun close() { disconnectAll() } diff --git a/desktop/src/app/home/home.component.ts b/desktop/src/app/home/home.component.ts index 14c62e8b8..cb52a0641 100644 --- a/desktop/src/app/home/home.component.ts +++ b/desktop/src/app/home/home.component.ts @@ -184,6 +184,14 @@ export class HomeComponent implements AfterContentInit, OnDestroy { }, ) + electron.on('CONNECTION.CLOSED', event => { + if (this.connection?.id === event.id) { + ngZone.run(() => { + this.updateConnection() + }) + } + }) + this.connections = preference.connections.get() this.connections.forEach(e => e.connected = false) this.connection = this.connections[0] diff --git a/desktop/src/shared/services/electron.service.ts b/desktop/src/shared/services/electron.service.ts index 186c73793..d24c8e079 100644 --- a/desktop/src/shared/services/electron.service.ts +++ b/desktop/src/shared/services/electron.service.ts @@ -20,6 +20,7 @@ import { Mount } from '../types/mount.types' import { SequencerElapsed } from '../types/sequencer.types' import { FilterWheel } from '../types/wheel.types' import { ApiService } from './api.service' +import { ConnectionClosed } from '../types/home.types' type EventMappedType = { 'DEVICE.PROPERTY_CHANGED': INDIMessageEvent @@ -52,6 +53,7 @@ type EventMappedType = { 'LOCATION.CHANGED': Location 'SEQUENCER.ELAPSED': SequencerElapsed 'FLAT_WIZARD.ELAPSED': FlatWizardElapsed + 'CONNECTION.CLOSED': ConnectionClosed } @Injectable({ providedIn: 'root' }) diff --git a/desktop/src/shared/types/home.types.ts b/desktop/src/shared/types/home.types.ts index 115668b39..633f05f16 100644 --- a/desktop/src/shared/types/home.types.ts +++ b/desktop/src/shared/types/home.types.ts @@ -24,3 +24,7 @@ export const EMPTY_CONNECTION_DETAILS: ConnectionDetails = { type: 'INDI', connected: false } + +export interface ConnectionClosed { + id: string +} diff --git a/nebulosa-alpaca-indi/build.gradle.kts b/nebulosa-alpaca-indi/build.gradle.kts index c2649cd18..375d4979b 100644 --- a/nebulosa-alpaca-indi/build.gradle.kts +++ b/nebulosa-alpaca-indi/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { api(project(":nebulosa-alpaca-api")) + api(project(":nebulosa-indi-connection")) api(project(":nebulosa-indi-device")) implementation(project(":nebulosa-log")) testImplementation(project(":nebulosa-test")) diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt index 3f92ed919..c1f6200bc 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt @@ -5,6 +5,7 @@ import nebulosa.alpaca.api.AlpacaResponse import nebulosa.alpaca.api.ConfiguredDevice import nebulosa.alpaca.indi.client.AlpacaClient import nebulosa.common.time.Stopwatch +import nebulosa.indi.connection.ConnectionClosed import nebulosa.indi.device.* import nebulosa.log.loggerFor import retrofit2.Call @@ -95,6 +96,7 @@ abstract class ASCOMDevice : Device { } catch (e: HttpException) { LOG.error("unexpected response. device=$name", e) } catch (e: Throwable) { + sender.fireOnEventReceived(ConnectionClosed(sender)) LOG.error("unexpected error. device=$name", e) } diff --git a/nebulosa-indi-client/build.gradle.kts b/nebulosa-indi-client/build.gradle.kts index 523dab9a5..30afff1d2 100644 --- a/nebulosa-indi-client/build.gradle.kts +++ b/nebulosa-indi-client/build.gradle.kts @@ -7,6 +7,7 @@ dependencies { api(project(":nebulosa-io")) api(project(":nebulosa-nova")) api(project(":nebulosa-imaging")) + api(project(":nebulosa-indi-connection")) api(project(":nebulosa-indi-device")) implementation(project(":nebulosa-log")) testImplementation(project(":nebulosa-test")) diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt index bd3baa130..a7f3e673a 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt @@ -12,6 +12,7 @@ import nebulosa.indi.client.device.focusers.INDIFocuser import nebulosa.indi.client.device.mounts.INDIMount import nebulosa.indi.client.device.mounts.IoptronV3Mount import nebulosa.indi.client.device.wheels.INDIFilterWheel +import nebulosa.indi.connection.ConnectionClosed import nebulosa.indi.device.Device import nebulosa.indi.device.INDIDeviceProvider import nebulosa.indi.device.camera.Camera @@ -42,7 +43,7 @@ data class INDIClient(val connection: INDIConnection) : INDIDeviceProtocolHandle override val id = UUID.randomUUID().toString() override val isClosed - get() = !connection.isOpen + get() = !connection.isOpen || super.isClosed override val input get() = connection.input @@ -77,6 +78,10 @@ data class INDIClient(val connection: INDIConnection) : INDIDeviceProtocolHandle connection.writeINDIProtocol(message) } + override fun onConnectionClosed() { + fireOnEventReceived(ConnectionClosed(this)) + } + override fun cameras(): List { return cameras.values.toList() } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/connection/INDIProccessConnection.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/connection/INDIProccessConnection.kt index bbeb75006..878b27d87 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/connection/INDIProccessConnection.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/connection/INDIProccessConnection.kt @@ -3,7 +3,7 @@ package nebulosa.indi.client.connection import nebulosa.indi.client.io.INDIProtocolFactory import nebulosa.indi.protocol.io.INDIConnection -class INDIProccessConnection(val process: Process) : INDIConnection { +data class INDIProccessConnection(private val process: Process) : INDIConnection { override val input = INDIProtocolFactory.createInputStream(process.inputStream) diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/connection/INDISocketConnection.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/connection/INDISocketConnection.kt index d372069ce..2098f2a50 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/connection/INDISocketConnection.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/connection/INDISocketConnection.kt @@ -3,16 +3,18 @@ package nebulosa.indi.client.connection import nebulosa.indi.client.io.INDIProtocolFactory import nebulosa.indi.protocol.INDIProtocol import nebulosa.indi.protocol.io.INDIConnection +import nebulosa.log.loggerFor import java.net.InetSocketAddress import java.net.Socket -class INDISocketConnection(private val socket: Socket) : INDIConnection { +data class INDISocketConnection(private val socket: Socket) : INDIConnection { constructor(host: String, port: Int = INDIProtocol.DEFAULT_PORT) : this(Socket()) { + socket.reuseAddress = false socket.connect(InetSocketAddress(host, port), 30000) } - val host + val host: String get() = socket.localAddress.hostName val port @@ -26,32 +28,15 @@ class INDISocketConnection(private val socket: Socket) : INDIConnection { get() = !socket.isClosed override fun close() { - var thrown: Throwable? = null - - try { - socket.shutdownInput() - } catch (e: Throwable) { - thrown = e - } - - try { - socket.shutdownOutput() - } catch (e: Throwable) { - if (thrown == null) { - thrown = e - } - } - try { socket.close() } catch (e: Throwable) { - if (thrown == null) { - thrown = e - } + LOG.error("socket close error", e) } + } - if (thrown != null) { - throw thrown - } + companion object { + + @JvmStatic private val LOG = loggerFor() } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt index 064607e97..b1b6e0bc7 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt @@ -26,13 +26,14 @@ import nebulosa.indi.protocol.DefTextVector import nebulosa.indi.protocol.DelProperty import nebulosa.indi.protocol.INDIProtocol import nebulosa.indi.protocol.Message +import nebulosa.indi.protocol.parser.CloseConnectionListener import nebulosa.indi.protocol.parser.INDIProtocolParser import nebulosa.indi.protocol.parser.INDIProtocolReader import nebulosa.log.debug import nebulosa.log.loggerFor import java.util.concurrent.LinkedBlockingQueue -abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser { +abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser, CloseConnectionListener { @JvmField protected val cameras = HashMap(2) @JvmField protected val mounts = HashMap(1) @@ -47,8 +48,8 @@ abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser { private val messageQueueCounter = HashMap(2048) private val handlers = LinkedHashSet() - val isRunning - get() = protocolReader != null + override val isClosed + get() = protocolReader == null || !protocolReader!!.isRunning protected abstract fun newCamera(message: INDIProtocol, executable: String): Camera @@ -73,8 +74,10 @@ abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser { } internal fun registerGPS(device: GPS) { - gps[device.name] = device - fireOnEventReceived(GPSAttached(device)) + if (device.name !in gps) { + gps[device.name] = device + fireOnEventReceived(GPSAttached(device)) + } } internal fun unregisterGPS(device: GPS) { @@ -85,8 +88,10 @@ abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser { } internal fun registerGuideOutput(device: GuideOutput) { - guideOutputs[device.name] = device - fireOnEventReceived(GuideOutputAttached(device)) + if (device.name !in guideOutputs) { + guideOutputs[device.name] = device + fireOnEventReceived(GuideOutputAttached(device)) + } } internal fun unregisterGuideOutput(device: GuideOutput) { @@ -97,8 +102,10 @@ abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser { } internal fun registerThermometer(device: Thermometer) { - thermometers[device.name] = device - fireOnEventReceived(ThermometerAttached(device)) + if (device.name !in thermometers) { + thermometers[device.name] = device + fireOnEventReceived(ThermometerAttached(device)) + } } internal fun unregisterThermometer(device: Thermometer) { @@ -111,6 +118,7 @@ abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser { open fun start() { if (protocolReader == null) { protocolReader = INDIProtocolReader(this, Thread.MIN_PRIORITY) + protocolReader!!.registerCloseConnectionListener(this) protocolReader!!.start() } } diff --git a/nebulosa-indi-connection/build.gradle.kts b/nebulosa-indi-connection/build.gradle.kts new file mode 100644 index 000000000..46b4907f0 --- /dev/null +++ b/nebulosa-indi-connection/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + kotlin("jvm") + id("maven-publish") +} + +dependencies { + api(project(":nebulosa-indi-device")) + testImplementation(project(":nebulosa-test")) +} + +publishing { + publications { + create("pluginMaven") { + from(components["java"]) + } + } +} diff --git a/nebulosa-indi-connection/src/main/kotlin/nebulosa/indi/connection/ConnectionClosed.kt b/nebulosa-indi-connection/src/main/kotlin/nebulosa/indi/connection/ConnectionClosed.kt new file mode 100644 index 000000000..5613cc68d --- /dev/null +++ b/nebulosa-indi-connection/src/main/kotlin/nebulosa/indi/connection/ConnectionClosed.kt @@ -0,0 +1,10 @@ +package nebulosa.indi.connection + +import nebulosa.indi.device.Device +import nebulosa.indi.device.DeviceEvent +import nebulosa.indi.device.INDIDeviceProvider + +data class ConnectionClosed(val provider: INDIDeviceProvider) : DeviceEvent { + + override val device = null +} diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/ConnectionEvent.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/ConnectionEvent.kt deleted file mode 100644 index 82aebd42b..000000000 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/ConnectionEvent.kt +++ /dev/null @@ -1,3 +0,0 @@ -package nebulosa.indi.device - -interface ConnectionEvent : DeviceEvent, PropertyChangedEvent diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceConnected.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceConnected.kt index dc3b55515..338625b9b 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceConnected.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceConnected.kt @@ -1,3 +1,3 @@ package nebulosa.indi.device -data class DeviceConnected(override val device: Device) : DeviceEvent, ConnectionEvent +data class DeviceConnected(override val device: Device) : DeviceEvent, DeviceConnectionEvent diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceConnectionEvent.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceConnectionEvent.kt new file mode 100644 index 000000000..74c66b958 --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceConnectionEvent.kt @@ -0,0 +1,3 @@ +package nebulosa.indi.device + +interface DeviceConnectionEvent : DeviceEvent, PropertyChangedEvent diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceDisconnected.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceDisconnected.kt index edafc4a3d..94a5f9a9b 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceDisconnected.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceDisconnected.kt @@ -1,3 +1,3 @@ package nebulosa.indi.device -data class DeviceDisconnected(override val device: Device) : DeviceEvent, ConnectionEvent +data class DeviceDisconnected(override val device: Device) : DeviceEvent, DeviceConnectionEvent diff --git a/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/CloseConnectionListener.kt b/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/CloseConnectionListener.kt new file mode 100644 index 000000000..951757920 --- /dev/null +++ b/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/CloseConnectionListener.kt @@ -0,0 +1,6 @@ +package nebulosa.indi.protocol.parser + +fun interface CloseConnectionListener { + + fun onConnectionClosed() +} diff --git a/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/INDIProtocolReader.kt b/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/INDIProtocolReader.kt index 0d9051d3e..f7b884a12 100644 --- a/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/INDIProtocolReader.kt +++ b/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/INDIProtocolReader.kt @@ -2,12 +2,15 @@ package nebulosa.indi.protocol.parser import nebulosa.log.loggerFor import java.io.Closeable +import java.net.SocketException class INDIProtocolReader( private val parser: INDIProtocolParser, priority: Int = NORM_PRIORITY, ) : Thread(), Closeable { + private val listeners = HashSet(1) + @Volatile private var running = false init { @@ -17,21 +20,39 @@ class INDIProtocolReader( val isRunning get() = running - override fun run() { - val input = parser.input ?: return parser.close() + fun registerCloseConnectionListener(listener: CloseConnectionListener) { + listeners.add(listener) + } + + fun unregisterCloseConnectionListener(listener: CloseConnectionListener) { + listeners.remove(listener) + } + override fun start() { running = true + super.start() + } + + override fun run() { + val input = parser.input try { while (running) { - val message = input.readINDIProtocol() ?: break + val message = input?.readINDIProtocol() ?: break parser.handleMessage(message) } LOG.info("protocol parser finished") + listeners.onEach { it.onConnectionClosed() }.clear() + parser.close() } catch (_: InterruptedException) { + running = false LOG.info("protocol parser interrupted") + } catch (e: SocketException) { + listeners.onEach { it.onConnectionClosed() }.clear() + LOG.info("protocol parser socket error") } catch (e: Throwable) { + running = false LOG.error("protocol parser error", e) parser.close() } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2b677f50a..bc7bc14b9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -63,6 +63,7 @@ include(":nebulosa-hips2fits") include(":nebulosa-horizons") include(":nebulosa-imaging") include(":nebulosa-indi-client") +include(":nebulosa-indi-connection") include(":nebulosa-indi-device") include(":nebulosa-indi-protocol") include(":nebulosa-io") From 9e01817c23a6233442c12941c802bdec352837c9 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Fri, 23 Feb 2024 23:52:10 -0300 Subject: [PATCH 48/87] [api]: Fix Astap plate solver was not setting the header --- .../kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt b/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt index 541ce21ec..cd10570a5 100644 --- a/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt +++ b/nebulosa-astap/src/main/kotlin/nebulosa/astap/plate/solving/AstapPlateSolver.kt @@ -110,7 +110,7 @@ class AstapPlateSolver(path: Path) : PlateSolver { header.add(NOAOExt.CD2_1, cd21) header.add(NOAOExt.CD2_2, cd22) - val solution = PlateSolution(true, crota2.deg, cdelt2.deg, crval1.deg, crval2.deg, width.deg, height.deg) + val solution = PlateSolution(true, crota2.deg, cdelt2.deg, crval1.deg, crval2.deg, width.deg, height.deg, header = header) LOG.info("astap solved. calibration={}", solution) From e69ad3a40600e0250c3ff4b152e56485d922dda7 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 24 Feb 2024 00:44:36 -0300 Subject: [PATCH 49/87] [api][desktop]: Fix and improve INDI event listener endpoint call --- .../nebulosa/api/indi/INDIController.kt | 5 ++-- .../nebulosa/api/indi/INDIEventHandler.kt | 26 +++++++++++-------- .../kotlin/nebulosa/api/indi/INDIService.kt | 8 ++++++ desktop/src/app/indi/indi.component.ts | 4 +-- .../nebulosa/indi/device/DeviceAttached.kt | 5 +++- .../nebulosa/indi/device/DeviceDetached.kt | 5 +++- 6 files changed, 35 insertions(+), 18 deletions(-) diff --git a/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt b/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt index 2b3296113..f69a95d6a 100644 --- a/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt +++ b/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt @@ -10,7 +10,6 @@ import org.springframework.web.bind.annotation.* @RequestMapping("indi") class INDIController( private val indiService: INDIService, - private val indiEventHandler: INDIEventHandler, ) { @GetMapping("{device}/properties") @@ -39,12 +38,12 @@ class INDIController( @Synchronized @PutMapping("listener/{device}/start") fun startListening(device: Device) { - indiEventHandler.canSendEvents.add(device) + indiService.registerDeviceToSendMessage(device) } @Synchronized @PutMapping("listener/{device}/stop") fun stopListening(device: Device) { - indiEventHandler.canSendEvents.remove(device) + indiService.unregisterDeviceToSendMessage(device) } } diff --git a/api/src/main/kotlin/nebulosa/api/indi/INDIEventHandler.kt b/api/src/main/kotlin/nebulosa/api/indi/INDIEventHandler.kt index 26effc19a..77855d479 100644 --- a/api/src/main/kotlin/nebulosa/api/indi/INDIEventHandler.kt +++ b/api/src/main/kotlin/nebulosa/api/indi/INDIEventHandler.kt @@ -14,37 +14,41 @@ class INDIEventHandler( private val messageService: MessageService, ) : LinkedList() { - val canSendEvents = HashSet() + private val canSendEvents = HashSet() @Subscribe(threadMode = ThreadMode.ASYNC) fun onDeviceEvent(event: DeviceEvent<*>) { when (event) { is DevicePropertyChanged -> sendINDIPropertyChanged(event) is DevicePropertyDeleted -> sendINDIPropertyDeleted(event) - is DeviceMessageReceived -> { - if (event.device == null) { - addFirst(event.message) - } - - sendINDIMessageReceived(event) - } + is DeviceMessageReceived -> if (event.device == null) addFirst(event.message) + else sendINDIMessageReceived(event) + is DeviceDetached<*> -> unregisterDevice(event.device) } } + fun registerDevice(device: Device) { + canSendEvents.add(device.id) + } + + fun unregisterDevice(device: Device) { + canSendEvents.remove(device.id) + } + fun sendINDIPropertyChanged(event: DevicePropertyEvent) { - if (event.device in canSendEvents) { + if (event.device.id in canSendEvents) { messageService.sendMessage(INDIMessageEvent(DEVICE_PROPERTY_CHANGED, event)) } } fun sendINDIPropertyDeleted(event: DevicePropertyEvent) { - if (event.device in canSendEvents) { + if (event.device.id in canSendEvents) { messageService.sendMessage(INDIMessageEvent(DEVICE_PROPERTY_DELETED, event)) } } fun sendINDIMessageReceived(event: DeviceMessageReceived) { - if (event.device in canSendEvents) { + if (event.device != null && event.device!!.id in canSendEvents) { messageService.sendMessage(INDIMessageEvent(DEVICE_MESSAGE_RECEIVED, event)) } } diff --git a/api/src/main/kotlin/nebulosa/api/indi/INDIService.kt b/api/src/main/kotlin/nebulosa/api/indi/INDIService.kt index 775518c39..e66b2ef2c 100644 --- a/api/src/main/kotlin/nebulosa/api/indi/INDIService.kt +++ b/api/src/main/kotlin/nebulosa/api/indi/INDIService.kt @@ -10,6 +10,14 @@ class INDIService( private val indiEventHandler: INDIEventHandler, ) { + fun registerDeviceToSendMessage(device: Device) { + indiEventHandler.registerDevice(device) + } + + fun unregisterDeviceToSendMessage(device: Device) { + indiEventHandler.unregisterDevice(device) + } + fun messages(): List { return indiEventHandler } diff --git a/desktop/src/app/indi/indi.component.ts b/desktop/src/app/indi/indi.component.ts index 765a7d381..13d2591cb 100644 --- a/desktop/src/app/indi/indi.component.ts +++ b/desktop/src/app/indi/indi.component.ts @@ -100,13 +100,13 @@ export class INDIComponent implements AfterViewInit, OnDestroy { async deviceChanged(device: Device) { if (this.device) { - this.api.indiStopListening(this.device) + await this.api.indiStopListening(this.device) } this.device = device this.updateProperties() - this.api.indiStartListening(device) + await this.api.indiStartListening(device) this.messages = await this.api.indiLog(device) } diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceAttached.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceAttached.kt index 7e3dfab1b..040e7f263 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceAttached.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceAttached.kt @@ -1,3 +1,6 @@ package nebulosa.indi.device -interface DeviceAttached : DeviceEvent +interface DeviceAttached : DeviceEvent { + + override val device: T +} diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceDetached.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceDetached.kt index e47619725..b36e97fb8 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceDetached.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceDetached.kt @@ -1,3 +1,6 @@ package nebulosa.indi.device -interface DeviceDetached : DeviceEvent +interface DeviceDetached : DeviceEvent { + + override val device: T +} From 6ead9e175172f0f12565f38f25e63ccc913af7eb Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 24 Feb 2024 01:46:26 -0300 Subject: [PATCH 50/87] [desktop]: Add Focuser hotkeys --- desktop/package-lock.json | 9 ++++ desktop/package.json | 1 + .../src/app/focuser/focuser.component.html | 12 ++--- desktop/src/app/focuser/focuser.component.ts | 52 ++++++++++++++----- desktop/src/app/image/image.component.ts | 22 +++----- desktop/src/app/mount/mount.component.ts | 2 +- .../shared/services/browser-window.service.ts | 2 +- 7 files changed, 63 insertions(+), 37 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index a92f85297..63bdaefba 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -24,6 +24,7 @@ "@mdi/font": "7.4.47", "chart.js": "4.4.1", "chartjs-plugin-zoom": "2.0.1", + "hotkeys-js": "3.13.7", "interactjs": "1.10.26", "leaflet": "1.9.4", "moment": "2.30.1", @@ -9240,6 +9241,14 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/hotkeys-js": { + "version": "3.13.7", + "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.13.7.tgz", + "integrity": "sha512-ygFIdTqqwG4fFP7kkiYlvayZppeIQX2aPpirsngkv1xM1lP0piDY5QEh68nQnIKvz64hfocxhBaD/uK3sSK1yQ==", + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + } + }, "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", diff --git a/desktop/package.json b/desktop/package.json index fa0d424f1..34c8ee6bd 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -43,6 +43,7 @@ "@mdi/font": "7.4.47", "chart.js": "4.4.1", "chartjs-plugin-zoom": "2.0.1", + "hotkeys-js": "3.13.7", "interactjs": "1.10.26", "leaflet": "1.9.4", "moment": "2.30.1", diff --git a/desktop/src/app/focuser/focuser.component.html b/desktop/src/app/focuser/focuser.component.html index 367b9011a..cb1a400f1 100644 --- a/desktop/src/app/focuser/focuser.component.html +++ b/desktop/src/app/focuser/focuser.component.html @@ -1,6 +1,6 @@ + + \ No newline at end of file diff --git a/desktop/src/app/sequencer/sequencer.component.ts b/desktop/src/app/sequencer/sequencer.component.ts index c344a2c6d..fe9fe2470 100644 --- a/desktop/src/app/sequencer/sequencer.component.ts +++ b/desktop/src/app/sequencer/sequencer.component.ts @@ -1,13 +1,14 @@ import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop' import { AfterContentInit, Component, HostListener, NgZone, OnDestroy, QueryList, ViewChildren } from '@angular/core' -import { MessageService } from 'primeng/api' +import { MenuItem, MessageService } from 'primeng/api' import { CameraExposureComponent } from '../../shared/components/camera-exposure/camera-exposure.component' +import { DialogMenuComponent } from '../../shared/components/dialog-menu/dialog-menu.component' import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' import { ElectronService } from '../../shared/services/electron.service' import { LocalStorageService } from '../../shared/services/local-storage.service' import { JsonFile } from '../../shared/types/app.types' -import { Camera, CameraCaptureElapsed, CameraStartCapture } from '../../shared/types/camera.types' +import { Camera, CameraCaptureElapsed, CameraStartCapture, updateCameraStartCaptureFromCamera } from '../../shared/types/camera.types' import { Focuser } from '../../shared/types/focuser.types' import { EMPTY_SEQUENCE_PLAN, SequenceCaptureMode, SequencePlan, SequencerElapsed } from '../../shared/types/sequencer.types' import { FilterWheel } from '../../shared/types/wheel.types' @@ -37,6 +38,38 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { readonly captureModes: SequenceCaptureMode[] = ['FULLY', 'INTERLEAVED'] readonly plan = Object.assign({}, EMPTY_SEQUENCE_PLAN) + selectedMenuEntry?: CameraStartCapture + readonly entryMenuModel: MenuItem[] = [ + { + icon: 'mdi mdi-content-copy', + label: 'Apply to all', + command: () => { + this.applyCameraStartCaptureToEntries(-1000) + this.applyCameraStartCaptureToEntries(1000) + } + }, + { + icon: 'mdi mdi-content-copy', + label: 'Apply to all above', + command: () => this.applyCameraStartCaptureToEntries(-1000) + }, + { + icon: 'mdi mdi-content-copy', + label: 'Apply to above', + command: () => this.applyCameraStartCaptureToEntries(-1) + }, + { + icon: 'mdi mdi-content-copy', + label: 'Apply to below', + command: () => this.applyCameraStartCaptureToEntries(1) + }, + { + icon: 'mdi mdi-content-copy', + label: 'Apply to all below', + command: () => this.applyCameraStartCaptureToEntries(1000) + }, + ] + readonly sequenceEvents: CameraCaptureElapsed[] = [] event?: SequencerElapsed @@ -260,18 +293,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { updateEntryFromCamera(entry: CameraStartCapture, camera?: Camera) { if (camera) { if (camera.connected) { - if (camera.maxX > 1) entry.x = Math.max(camera.minX, Math.min(entry.x, camera.maxX)) - if (camera.maxY > 1) entry.y = Math.max(camera.minY, Math.min(entry.y, camera.maxY)) - - if (camera.maxWidth > 1 && (entry.width <= 0 || entry.width > camera.maxWidth)) entry.width = camera.maxWidth - if (camera.maxHeight > 1 && (entry.height <= 0 || entry.height > camera.maxHeight)) entry.height = camera.maxHeight - - if (camera.maxBinX > 1) entry.binX = Math.max(1, Math.min(entry.binX, camera.maxBinX)) - if (camera.maxBinY > 1) entry.binY = Math.max(1, Math.min(entry.binY, camera.maxBinY)) - if (camera.gainMax) entry.gain = Math.max(camera.gainMin, Math.min(entry.gain, camera.gainMax)) - if (camera.offsetMax) entry.offset = Math.max(camera.offsetMin, Math.min(entry.offset, camera.offsetMax)) - if (!entry.frameFormat || !camera.frameFormats.includes(entry.frameFormat)) entry.frameFormat = camera.frameFormats[0] - + updateCameraStartCaptureFromCamera(entry, camera) this.savePlan() } } @@ -334,6 +356,71 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { this.savedPathWasModified = !!this.savedPath } + showEntryMenu(entry: CameraStartCapture, dialogMenu: DialogMenuComponent) { + this.selectedMenuEntry = entry + const index = this.plan.entries.indexOf(entry) + + this.entryMenuModel.forEach(e => e.visible = true) + + if (index === 0 || this.plan.entries.length === 1) { + // Hides all above and above. + this.entryMenuModel[1].visible = false + this.entryMenuModel[2].visible = false + } else if (index === 1) { + // Hides all above. + this.entryMenuModel[1].visible = false + } + + if (index === this.plan.entries.length - 1 || this.plan.entries.length === 1) { + // Hides below and all below. + this.entryMenuModel[3].visible = false + this.entryMenuModel[4].visible = false + } else if (index === this.plan.entries.length - 2) { + // Hides all below. + this.entryMenuModel[4].visible = false + } + + dialogMenu.show() + } + + private applyCameraStartCaptureToEntries(count: number) { + const source = this.selectedMenuEntry! + const index = this.plan.entries.indexOf(source) + + if (index < 0 || count === 0) return + + const below = Math.sign(count) + + count = Math.abs(count) + + console.log(index, below, count) + + for (let i = 1; i <= count; i++) { + const pos = index + (i * below) + + if (pos >= 0 && pos < this.plan.entries.length) { + const dest = this.plan.entries[pos] + + if (!dest.enabled) continue + + dest.exposureTime = source.exposureTime + dest.exposureAmount = source.exposureAmount + dest.exposureDelay = source.exposureDelay + dest.x = source.x + dest.y = source.y + dest.width = source.width + dest.height = source.height + dest.binX = source.binX + dest.binY = source.binY + dest.frameFormat = source.frameFormat + dest.gain = source.gain + dest.offset = source.offset + } else { + break + } + } + } + deleteEntry(entry: CameraStartCapture, index: number) { this.plan.entries.splice(index, 1) } From f08a50a47f723cb364f2d97e5e1dd4a121493203 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Mon, 26 Feb 2024 00:51:54 -0300 Subject: [PATCH 62/87] [desktop]: Allow select the sequence entry properties to be applied --- .../app/sequencer/sequencer.component.html | 63 +++++++++++- .../src/app/sequencer/sequencer.component.ts | 98 ++++++++++++------- desktop/src/shared/types/sequencer.types.ts | 8 ++ 3 files changed, 133 insertions(+), 36 deletions(-) diff --git a/desktop/src/app/sequencer/sequencer.component.html b/desktop/src/app/sequencer/sequencer.component.html index 81a75ef9d..0819149cd 100644 --- a/desktop/src/app/sequencer/sequencer.component.html +++ b/desktop/src/app/sequencer/sequencer.component.html @@ -214,7 +214,7 @@
-
+
@@ -250,4 +250,65 @@ styleClass="fixed" [style]="{bottom: '16px', right: '16px'}" />
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + +
+ \ No newline at end of file diff --git a/desktop/src/app/sequencer/sequencer.component.ts b/desktop/src/app/sequencer/sequencer.component.ts index fe9fe2470..01ff05843 100644 --- a/desktop/src/app/sequencer/sequencer.component.ts +++ b/desktop/src/app/sequencer/sequencer.component.ts @@ -10,7 +10,7 @@ import { LocalStorageService } from '../../shared/services/local-storage.service import { JsonFile } from '../../shared/types/app.types' import { Camera, CameraCaptureElapsed, CameraStartCapture, updateCameraStartCaptureFromCamera } from '../../shared/types/camera.types' import { Focuser } from '../../shared/types/focuser.types' -import { EMPTY_SEQUENCE_PLAN, SequenceCaptureMode, SequencePlan, SequencerElapsed } from '../../shared/types/sequencer.types' +import { EMPTY_SEQUENCE_PLAN, SEQUENCE_ENTRY_PROPERTIES, SequenceCaptureMode, SequenceEntryProperty, SequencePlan, SequencerElapsed } from '../../shared/types/sequencer.types' import { FilterWheel } from '../../shared/types/wheel.types' import { deviceComparator } from '../../shared/utils/comparators' import { AppComponent } from '../app.component' @@ -38,35 +38,50 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { readonly captureModes: SequenceCaptureMode[] = ['FULLY', 'INTERLEAVED'] readonly plan = Object.assign({}, EMPTY_SEQUENCE_PLAN) - selectedMenuEntry?: CameraStartCapture + private entryToApply?: CameraStartCapture + private entryToApplyCount: [number, number] = [0, 0] + readonly availableEntryPropertiesToApply = new Map() + showEntryPropertiesToApplyDialog = false readonly entryMenuModel: MenuItem[] = [ { icon: 'mdi mdi-content-copy', label: 'Apply to all', command: () => { - this.applyCameraStartCaptureToEntries(-1000) - this.applyCameraStartCaptureToEntries(1000) + this.entryToApplyCount = [-1000, 1000] + this.showEntryPropertiesToApplyDialog = true } }, { icon: 'mdi mdi-content-copy', label: 'Apply to all above', - command: () => this.applyCameraStartCaptureToEntries(-1000) + command: () => { + this.entryToApplyCount = [-1000, 0] + this.showEntryPropertiesToApplyDialog = true + } }, { icon: 'mdi mdi-content-copy', label: 'Apply to above', - command: () => this.applyCameraStartCaptureToEntries(-1) + command: () => { + this.entryToApplyCount = [-1, 0] + this.showEntryPropertiesToApplyDialog = true + } }, { icon: 'mdi mdi-content-copy', label: 'Apply to below', - command: () => this.applyCameraStartCaptureToEntries(1) + command: () => { + this.entryToApplyCount = [1, 0] + this.showEntryPropertiesToApplyDialog = true + } }, { icon: 'mdi mdi-content-copy', label: 'Apply to all below', - command: () => this.applyCameraStartCaptureToEntries(1000) + command: () => { + this.entryToApplyCount = [1000, 0] + this.showEntryPropertiesToApplyDialog = true + } }, ] @@ -204,6 +219,10 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { } }) }) + + for (const p of SEQUENCE_ENTRY_PROPERTIES) { + this.availableEntryPropertiesToApply.set(p, true) + } } async ngAfterContentInit() { @@ -357,7 +376,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { } showEntryMenu(entry: CameraStartCapture, dialogMenu: DialogMenuComponent) { - this.selectedMenuEntry = entry + this.entryToApply = entry const index = this.plan.entries.indexOf(entry) this.entryMenuModel.forEach(e => e.visible = true) @@ -383,42 +402,51 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { dialogMenu.show() } - private applyCameraStartCaptureToEntries(count: number) { - const source = this.selectedMenuEntry! - const index = this.plan.entries.indexOf(source) + updateAllAvailableEntryPropertiesToApply(selected: boolean) { + for (const p of SEQUENCE_ENTRY_PROPERTIES) { + this.availableEntryPropertiesToApply.set(p, selected) + } + } - if (index < 0 || count === 0) return + applyCameraStartCaptureToEntries() { + const source = this.entryToApply! + const index = this.plan.entries.indexOf(source) - const below = Math.sign(count) + for (let count of this.entryToApplyCount) { + if (index < 0 || count === 0) continue - count = Math.abs(count) + const below = Math.sign(count) - console.log(index, below, count) + count = Math.abs(count) - for (let i = 1; i <= count; i++) { - const pos = index + (i * below) + for (let i = 1; i <= count; i++) { + const pos = index + (i * below) - if (pos >= 0 && pos < this.plan.entries.length) { - const dest = this.plan.entries[pos] + if (pos >= 0 && pos < this.plan.entries.length) { + const dest = this.plan.entries[pos] - if (!dest.enabled) continue + if (!dest.enabled) continue - dest.exposureTime = source.exposureTime - dest.exposureAmount = source.exposureAmount - dest.exposureDelay = source.exposureDelay - dest.x = source.x - dest.y = source.y - dest.width = source.width - dest.height = source.height - dest.binX = source.binX - dest.binY = source.binY - dest.frameFormat = source.frameFormat - dest.gain = source.gain - dest.offset = source.offset - } else { - break + if (this.availableEntryPropertiesToApply.get('EXPOSURE_TIME')) dest.exposureTime = source.exposureTime + if (this.availableEntryPropertiesToApply.get('EXPOSURE_AMOUNT')) dest.exposureAmount = source.exposureAmount + if (this.availableEntryPropertiesToApply.get('EXPOSURE_DELAY')) dest.exposureDelay = source.exposureDelay + if (this.availableEntryPropertiesToApply.get('FRAME_TYPE')) dest.frameType = source.frameType + if (this.availableEntryPropertiesToApply.get('X')) dest.x = source.x + if (this.availableEntryPropertiesToApply.get('Y')) dest.y = source.y + if (this.availableEntryPropertiesToApply.get('WIDTH')) dest.width = source.width + if (this.availableEntryPropertiesToApply.get('HEIGHT')) dest.height = source.height + if (this.availableEntryPropertiesToApply.get('BIN')) dest.binX = source.binX + if (this.availableEntryPropertiesToApply.get('BIN')) dest.binY = source.binY + if (this.availableEntryPropertiesToApply.get('FRAME_FORMAT')) dest.frameFormat = source.frameFormat + if (this.availableEntryPropertiesToApply.get('GAIN')) dest.gain = source.gain + if (this.availableEntryPropertiesToApply.get('OFFSET')) dest.offset = source.offset + } else { + break + } } } + + this.showEntryPropertiesToApplyDialog = false } deleteEntry(entry: CameraStartCapture, index: number) { diff --git a/desktop/src/shared/types/sequencer.types.ts b/desktop/src/shared/types/sequencer.types.ts index 4ff02c125..a40396f57 100644 --- a/desktop/src/shared/types/sequencer.types.ts +++ b/desktop/src/shared/types/sequencer.types.ts @@ -4,6 +4,14 @@ import { FilterWheel } from './wheel.types' export type SequenceCaptureMode = 'FULLY' | 'INTERLEAVED' +export const SEQUENCE_ENTRY_PROPERTIES = [ + 'EXPOSURE_TIME', 'EXPOSURE_AMOUNT', 'EXPOSURE_DELAY', + 'FRAME_TYPE', 'X', 'Y', 'WIDTH', 'HEIGHT', + 'BIN', 'FRAME_FORMAT', 'GAIN', 'OFFSET' +] as const + +export type SequenceEntryProperty = (typeof SEQUENCE_ENTRY_PROPERTIES)[number] + export interface AutoFocusAfterConditions { enabled: boolean onStart: boolean From c29d0aeb858b544400775cebcff40f96a04a7f57 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Mon, 26 Feb 2024 22:49:24 -0300 Subject: [PATCH 63/87] [desktop]: Calculator --- desktop/README.md | 4 + desktop/calculator.png | Bin 0 -> 15810 bytes desktop/src/app/app-routing.module.ts | 5 + desktop/src/app/app.module.ts | 4 + .../app/calculator/calculator.component.html | 24 +++++ .../app/calculator/calculator.component.scss | 0 .../app/calculator/calculator.component.ts | 94 ++++++++++++++++++ .../calculator/formula/formula.component.html | 35 +++++++ .../calculator/formula/formula.component.scss | 3 + .../calculator/formula/formula.component.ts | 25 +++++ desktop/src/app/home/home.component.html | 6 ++ desktop/src/app/home/home.component.ts | 3 + desktop/src/assets/icons/CREDITS.md | 1 + desktop/src/assets/icons/calculator.png | Bin 0 -> 2243 bytes .../shared/services/browser-window.service.ts | 5 + desktop/src/shared/types/calculator.types.ts | 19 ++++ desktop/src/shared/types/home.types.ts | 2 +- 17 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 desktop/calculator.png create mode 100644 desktop/src/app/calculator/calculator.component.html create mode 100644 desktop/src/app/calculator/calculator.component.scss create mode 100644 desktop/src/app/calculator/calculator.component.ts create mode 100644 desktop/src/app/calculator/formula/formula.component.html create mode 100644 desktop/src/app/calculator/formula/formula.component.scss create mode 100644 desktop/src/app/calculator/formula/formula.component.ts create mode 100644 desktop/src/assets/icons/calculator.png create mode 100644 desktop/src/shared/types/calculator.types.ts diff --git a/desktop/README.md b/desktop/README.md index a31cbde6e..054c3dc07 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -54,6 +54,10 @@ The complete integrated solution for all of your astronomical imaging needs. ![](indi.png) +## Calculator + +![](calculator.png) + ## Settings ![](settings.png) diff --git a/desktop/calculator.png b/desktop/calculator.png new file mode 100644 index 0000000000000000000000000000000000000000..abee956d6a13e17999aaa34d43e0b72561f24e85 GIT binary patch literal 15810 zcmb`ubyQaEyEXa%N*N$1AdLt}OE)MI(uzozbR*pjN~d%Q2#9oxq#yzUA|Tz}-Tht9 z``dr)an9LiyyrWgV=yGvv*Nz5nAepl7y*Z|qI2ttgdDEe#QNhQ^fa+>}O!j+E?=**PiM+4(s*_}SQsJx}Tp2ug&M z*i$9vl#K}&ciE%MwwVF4c}rT$z@0VBIoQvz5`Qn59vCv+bTaSD`ur|GoU{(X(^$%M zTFpA@B}m>_Ceq!uDP|n^1)DHT!cRKeb(LKr>rTD4&XarhnC`PUlgja_dy@ZncVW!u zHpBF$R)ww7T8q42=Y(R&=bBF70}IDmN#VCWIoS*I&&0Hbo2D5Q)C!al`d316-s&!s z-tCg3>V8<-JhAd=xb^nzH+ru0eg5Lo%6;(mX!`ygg^n)Q_>zA5%jYqgNXOR{SEdey z6i;+Xirq(|{nz!6-Pt2PW-#}X>$J~X8; zUeFb?qHG<1tiKe{!Ab8Zn+|Xna->~T5Sx3^jZs+6ine4i{b%J-=I?i<4&Qa%Qz=ZZ z6|=ljs}u1~s>2xAqcyClL#GhX#WuWuG@M^u_$dwg)D<)0chYcb5? zD_NaYu;pAOZyiLy%$%D}^R*gn^a~ENlLceQyVF_huZOp285lxu(O-^ll3tX{mBVK(UvQjYKoLv~MtW7nllANun?Z*&ssR zWbzo5eg&moTfxX^Q9s>n(78}gky`cG#_QPTP75JMMk-<0)e?#ckJnFcHY%~De%w-j z3-VTqk?*C4`dCDKA&4+;x0JyFt~QK(F+bX46Nhz8;Y34o2a6!Vuwo5Co73Ml!@l8x z^=CnD9+VVa>!E|qD1T-a?UO@xM)Hb7A}B|#C>sVUeiWy+%x4Sq2AG*1{8U}#-fLwb zrA$oSlVIZ$J5>qpl`Oy1$ucctPB6Xk=5G^9=&W&0<&d6bxI&ycKB_QjXvE@5h?aqj z&wGoEgQ<@LENZRW#Ts~xCmi1v^qYKd5(-LF$n7(K3?&rCi7ppKyz*1I!}<9RvvA_C z?ZexoM3Zq6T1&VY7-@f-tUobb2wPB-1A`-~Qz zv`zLzV_W|Se#L9lgsQiXyU3b8cc>c7XG?XuP5HD1v>WwzC?3HhC7UWv$q-y zwk0MH`t2rph@W8|WIg>BwL+@m)Sh=kacOt(&%@b+>3$~fre%{=F6Z$ePUi5@^~TNt zt2PWzVVr@SXYUCVrV#29>vK=$aaXHOyl3hs0{3qHkc{d2UEkL6)>)yng_>M%ESOyA zibpV#tYSoHG|{hZR!9H-9myNp$v?7d?DP4mr>j)12@0Z*S?#h}swNt#@^PKXhWuWoWxzxGmuO!E*F1@#wWHx!z)8`T8 zk@0bCr}pVJclsCF}c6Ifg)s}UEK&)(ye9K7_j}PC~Y0^}&^PS^vrFUnmyxZwLMAga-=mZIW zncXL5m?u|{39KE+<=P8FNF(;TH+wI)@7u|DypVenH}`NT;XsVe7>De9&tN!2@%0y_ zjs#X_td}XK8te{ZxGd=5v@Y{Kp@oj#Hd_%!7Y=rWMxMWa|2E+y4*l6C877_GY{D5G z8TrEdb*#SbHaeeS9s2I(+hASHQptZO6PGlOg-%0=Le)r$b^>5l^ zYdK*DS*3cIa=v(0@4QNkDCW!QeODp=)Z2dFBUv{VWE7MOmG#Zd8BrMhomWgbi7hNG zU*_*Eg?KhpWDc0^W!Uajom)#*?p5to@#cw4Rgitl$_-HC4wCuON^y=Ija79+6n zxCnF{@~+E^Gd`PHf4#B0qIb2glb7MhCUFLJb;;UUYO|>q5{QV1yaH_T!O+~iGl5NC zwbJY!okE)XybB{EBl=CErh$BwkvbPUpCRL_NRO0n4r_f49&QM$iEpjHVi{|^u7u4h zlbj}9-Y0RHXAZ6Ly`4sQK{@B<=C-9HgM))#YisRR`e@=?`PRnf&Dvn$`gje;RCpq8tCpD=gJd`<6(yy2Xz0z~ zX+q|%zx(>4kEXmR2nh+53)KzdPO-Za3TkTwCTg9GH^*yaQ+T72d93@&jTI2^9Pg5o zN$_c9bw?{qIW2}G;Z|N2Y4WjXefu%r5tAtF!GC#qIXpJD0jk+ zujUskmg`1up>?p-kn z33LvlE=p$RTL{1JO|Z=;muFim@F)zi3|_aBa8bPv)`rThr-V=USDp=gd5uz1Qu5L3 z>hkN?uek7WclSCqGY$;}$F2l6qA&?WmF3vG%1SQd-zijVYp|{Ag>No1T8s5l!Efu4En@SQvjS{IuG%LXGM&r>#lSh?A2OAxE{W z-UMs96@T25rLD#*iQN93Nw+PVqfM=H45qu8GrsNZ&+F>zZ5$oZKf3HKBJUNR-oW7D z!Ro*>$l^C|zL&gd`K*|Y9UdP3DLebkVIe2}(K2lzHdAc?{Jp55H&v`D4 z=dHcH`|PgM=J{06nR zw^y^)@s^vLTYqHB94D-gz3&a(xu0R{Q;k#hBk>YhGY}@@jb2wlmP9wvAzyygZZ(9c z7t-?ckNz!}lapH*ud#FA>*kS77Zyqn566AL+T7C8t>qi^&urlhFmcIh-bJ3fe*Meq z{GYJV9qsKOZRURd%~$EM=kmeLG;vlpUQ;7?Ct=|f8i@}pFXu?*cW7>I_WkhTuII&p zie3Ce|406x=wRMZh@=o25w)9@!K4a)@UKUb6TC9ldSQ{IJEDQ2)Qz^xI--Z)FO&mt zGe5T%Yj(y*auMD9l#?SJp^UlH*S~fE-!F~{W$kUCv!VMv-8^LV$GxKbKiOygO>g*b zW%>W{@F0n-H-iOgr2O`Qqm|~#wJD-4LqqY%40JoRu#117E!xU$fQE|7>%2|b7EY!Q zUlo>Phly(t8#5al7n)2bI*cBFYA zWYRe{mYgG(j7Ls>IlgiJ>STAJ=Iu-~b;Qx?KnRUY-29j$d3%pO>SKphr5xGBW(e}# ztUAHc^76U89ucoa+Zq=yti4BZRAXOWGfG5|%k`uQ<*5{tr@QZB*)9FfhKglWh`dIT zR_&rG8L~fotosasZF{`j zAyImB#Pi~m&3aO>NV5jDX{6jZkxd_+oSYmHO3D{q?{-uMIpFK-TT)Ts{wb`f>B(&} z{vY4{FtD((Me_&xzS`(lSx;H18y#=Y$R<3ViE@~7-;;CP9Pikis2!;=#ZOC1gXIY; z)o<(j{E~XHCmqM&{U$646c<(!9xG(?;2TYZT!Ph~_rnp^-JbrAfK-Nx`6G&2^7w44 z(Z{I{vCdLux7VAF9mgr z^>Q!$Dk05lAy(yhqk5{Os>%pjBrh+o;0~|Qx>T`4cM=!Yty>Gz-~BAcs%}wGP*B!Z zH|n?DzD>&81gDWp4WlEAL7s2E{Z1US#!vWCt+q;CDc>tNQnOKFdR8SGfUWt>p1w_A z(9rt;S%^M+cAww=QND7a-tK${GJPV1PcfnK&$fo#XVbd|cdGy0_quGFAfKcF zltF#?Vp1*fMk0V0UzF&Lx|78H&~JTp-g&UqaAs9B3T+$z1_2YYWbSWHkiZ`9E_9y2 z)l9~}vcawCLB>E=>o3+3ff_CghnWt1xrc&+!fEoy8*blbd-^G$011E=M8fX8kWyU( zY>9#{?6r;?nWp_Yb!VHkU1>rj0FV$B>dYIXm09q3p(H%Fq04Vg*5_%~utU>cN_CoK zv!ttTn}4G)Hk2mh7M+$RA3-iOTxJ+znhF5S^K63$HWk(2imuXjLAtK4E`i^H0U${m z)C~^f-;ecwh8k~r1>w;U@xG;e_U?I25M>YP=tK^9Q4~B1B_qhY zGsQ!Y54gA3on<}M0PVcYens(T=zYK9V%E-hR%7^9L5LP-^M<{_m$4$F@PS2(*n4BFTA!0RC^%R&Tk(yx zwF2zz$y|g;dwV-2;E=$;KtZyY@y#_T9BIRw5EW{rde^Uxysjhxo~{fQk^r#tfA`LI z?&mFp=lPE0g9i`D@1q~*CL|E`^DFFC%xUS zQ*N#u1P(&ESiAdoszAD{ECLS?@42k(HC>2cCXK4~nyGSeEhvqjKR^1Sl-CRg$wk~E z<&BJ?Q_y=%bbP+o1KUE!$2anq#8p*7LLxj;UPgwRnRz4V`^94jHe|f;JFY(m2vhHR zkSUkU9l>PGNh~ET4O>spQyp`at(cdVUkIT0Ci=7Rw!dav0ILlQ4CbBBp>QH@l5mH@ zGvnjpK0%_qpKJhJ0F5s|p7m%Reis(n!Z=R%IjZpRNZMQK!Ka{5(AA}^wwjoQc4h#S zi-`=NCR5WO^}^o+XLD+44k>Ae>cy(1zNrFEeVO7npa3J+gM{0n{bjs%F&zubZAr;C zI`SB=%iI%PUgpNha&fs*QWn)ZXEsRKOpB2+gi^k87*u3D5|WVj?=ehGO`+A#Zf?qF zNraJ~ynwrohr*iBkQq)PlGY=97CJnvDkvlbrLwiJZ?}|AXMKIW1R5KxrK+0R%+{8* znFuvC8uTIri+0^}fV!|!s7;-nAt-3*f_>UXMzmE`RSFYR<5dSGkQ6bov4+HZdj|)t z0|T+p1WMhGtR_9rETOTLl$GgSoE{*n77h;1=48E$vNAzKL&Mja8liHGqo!~&0U~aT zguGnX64k$}bd5qs8)K4Bo*-athet*de@fW7$=cQy_msJx*wV@-G(iVN4AHPI;w*F{xZp^yT zu-P9A;OU=QC+oicezhu#``X&t0L3w&h(nMgq0>;Yj&X0Mc#5D)-+nGMK~0#e5{o-v zbUi*Cv*vR@ehjD@dV_FZjO#G8RV1{5^0+WqsDTXPWA*a_{pc|dk2sj!9=q%s3>NF) z7+@Siqf&)ppk5dkLdcrLa*@Pgd>fhw<>SYZFmXvX+!2)5(|aJ`xIR5xs$XF>ahmwg z2F2@mb^txHVVr;Gr6^a#@4zkk0gi7SNR^%q=x z{Fe6=p2vSJBVdr#sJ3E*am{$s?qwE~h3Gq!^z8xwHm4Nkd*tkRo$a$~Watk+(P>~lZwfv)vfk<%BMa^UQNakWEWrC( z!3*EIVr&eM46tOAdP5S1AMsaSU29bK=++9)r2V}tb)j;x?Ui+=QjzL=>3e2TiFtFX z!`z+;S6C}6^gWtep1-u-gKD#OP_whm|Y#%#p5J}cE&FDb7Z=6F&(cTuq3%zbEkI<771bR{hn z-{;P~+}2jqU!v_cxqEdSVE($<*w6PxKG&x;#YDk!LMJ?H8`@oj!&+U`Qq}69i{Gz% zYtt_tIC01ZHcN_DMG6p9-SW-}n=;z+;Uc{?Cf(*ZvZrY|QTpK^dFf^KZ;~IaI@H{o z*bAL5{dBsdMcHLr#;zQTc=Z+tj=_4Dj}DL11|v4*7u{W{8H0TW!p%<#RACbePJc-_Sm z6s0^R3;_+v2fxX5M|r4BD!DBtc5b-g6wq)n2eFgCULN zh|@Z|gsx3?#dG?qUtr70g`H^MVzMgIPm()2&NHGcTPMTr?7LFl{uk3ktus&Kv(0-C z>85^m7ow7ld59Gf7o(N9T`@RrT%`K{AW9+CGVSH`RxTEqx}{rCS*66J6C)5*{k)Fk zM$<`%r#83SuVrlZRnOJ80-Qh0X*yEMP|4sw-7`m`%72GP8; zBJQ(e3RI#wRbzjkzrc=XNjF#twn03knKDxTBbn0Z`~iERuC%Pe2p*$A`^qEy;A*la z^uu2RCg#dI^?a^{U&?47&0<~udKFGkkGVz5#4F&DmjVVgS7P8=hb54Q^%LDS?@|zJAE=(3AGAizJu!an=#T^s1r3I>70TX8`LMmaRE`2e~Kgjp> z%m{9coEmbx3J)5g77@qX6G93KazM{jK5SbW)Kv5Uo6 zO{nLV{Pb38C(7~i#eL`3o?#ocDkVlOO!8@?K@m=`PWO+4che?6I2Ly`Jo75EJy7~B zMm}FT8X=b z(aXD}SRV4_z)Ay?E&CRUWrBS1n?y6~^9*oIu-UdKC+o{XwU>bQ-qa zf#$Sv<_GE8-s`EjdnNy=PBGi6A+$Blp5Br;Cors0)X3;i^Tn%%wN(9@F(>lW$0aZ8 z50wfP3Ec$f&c_aOVGGiUN)W&0$v@cA>z~#rb(A6N<@b-dnj@>cd`1d!s^tcv4w_mErCtYrA zs{6e0^ZjGO^w#FE>6c>XsGMD3NUgMlk!#v04{_LdU93>2ZP;o<&P7SN!$bNLZl{=8 zxie;L99W9g*#-P@xnPXxvEY>!eguMy0OPw}vy6e<@^k>GJ#iatXuu}sK1eP#8g>aWGZ8=ti+sXR~{X5nruuuoR< z90Y&k^dH)7c|OolSP>oHFn>5+BV~H@OH2#P*k)VhE3eD8=T^{XE@EHLOTnRyFG>td zoN)$3`+V|D#9<@-J%Sq|G;f62X*He;5ivYkQKW4%lvXdiCDl<~Lv+xXs2j6IgNRH^ zYpz`8Kzxnc{UmG3iqM-YR)mc(8?x|8O~t3t`lWj?HMun>m~g)RR6!}fJ8gW&Q~I!= z0^QH&Lq+b?{KQ1z1GCC^fBza3Sgz-3m0uU0l&f(PiP}x8N#++S9@k|2sB8}H7gefb zq`xvfOc3$JG=c#btkXwKQvS{4*r|NqFimMU5~YF>b=1C8saPx0U65qrb9Ut2ult0GT!5-2N6&8Q&xhj)7OU`Kh~BI&gC0(UM|lP(g8?i z*b&~g6Ioz!tP1C25khsYR5nM*`&+jiPfg0pZ5cgrj2O({HLn}mBSC1U2pzlsaW_-y z6kf6A;`b#Ua9<&d+*>;n;W8Z#GArh!q~=JibDLG>g4$~$@hwyq5MATg^Q`x?_O@iqPqw^tQpL1c`<1+f^m-_vnVCg!jx5bod>Ol z{WZ$xBQuR}jY6c5ax4;Lxk^$Br$>om+1Qov3{O@Y7u!JFhgRlORXB-8F7hsg1Y-Eb z6_?0ObTo}>){GpdSNs)cxH2-#QG~bq3v3823O1nK!S?wB72*~j_2JQssOI@==zSEX z<&ND1`?rsISmenyzmdKR3lp+-B9h*touGK-^XR5Qc(JJN@9Oty=Jgmu9aW~iXF>WE zna(8aWl=RUkE#s9SwSg8{Kv_|w{w8EXFgEfJ{2ny7!QK=4WF|kd?4%}`ObU1=6&8y zfa+zHL`{JiRZK1`LfxfM#DWW!p!R|5pid(Hw>&|%ghhm(l=DF<1hv>MKAO?Bo;+3l z=m*~?kW*w_%UGZMqjR41U1Rc=$of7w$gPuuAQTl$cJI^nR?hcW-Gjjl2)_VgF0;EY z#A}WUMgwq9o{1h_7tF`dsCBetXJ`McReNY>{VZE?W@)LdjlwG>-_UN&aMk0+4torn zTEOQ4Gk14)Of0Mn7;9nRRLl^)etNjk2^@~i+qb$vtEC8gB*ECz4;(Md-stg01#U+>j8KCB2SU#cM2bJM!GNJDcE>%NYb6m)As8ZHVFv{ayCLz+`!7I z6l=pAl(4^VZ%1*PDwuhtgW*SKCsGIVLBn`1FQ4Oju!?~ByTqUa52@xPTQ^88{E9Ul zt)Pcla2m*yl*VE6^4CJcxeAsaV(dU__Eh7@2UUzQn2!9~M>s|q9T zu*eooi19BFH5)1lOHCyMCL76M{mpyTwm%L`_*a`bba{DsU=>i1Q!fZsrPb9u%ucI+ z=@k_fZ7b`Smrbn3zxw3oGlDX8lav=%Qc{wXlyujr%^%n0LCf`?@6FA#dHPAjVd%i( zf&kGH%c!!wz5UU+Cyi9Zlhm8t@YnRhf;ddCAV49`L0*J`T;I?zAUoSI{174^NFty^ zW1^y-<|$^wC!H;BfsW_w>beVh!QA2^aJ4*Xs!J_CEWvSkI`0=XG)zs8Fe8S$ceYAvA9iJ@#g8JXiF*|Kt&a zh+7$sfW19_r)ToV=VRhwT{{OUX*{|CkFd?oIC-3~Sjq0kFh3z=)iwYXb&=&+;F6L4 ztLAZQ4 zdP%m?X@T(8Zu0(r*#fa&<_{d7p6+(@Og*+={zKa1aWvrsG+O|KXsKyGEh-usipg*Z zmB`gO2a*!LJU_{CBg5P&Gw;v2#bZ7B5ZDf2>)!(Z8ONmN3o!>9GY={NG82KXN*)~f z=D>*Lh=51Xtg#IM25Gd~I-;>r1k@~Qetw|M2VZ=%H|5wIDVJ(z_@t073|z^mJx1aC zScccZkY#-t&oKDymz&BAJ4>pom)43Kjn0pE_E-AfLzJ=W{lMgS%_?@;Z&ryU<3Sk$ z3&ImnE`avHgiB<>gD`1*`;hl47g;X)`H@spj4Ae`}gk_6W{K6uk`2c{TA4IY%?nc zv>T|Mdh&aEvFa;4FX)^)kf05}GW|V?~Y?ZgTm>4b| zUUQGg)lGx+=_C;^;lsnjP%?oyAZI;KSLph8`*RhRpu#OOs9^5+8k{ByMsr-w8h-q{ z&w+R*@r!e>hN`~@^JOT_WPR(!huR21auTQP=@9x^Xl0MyIj*a9;OvP|(Vut?nWqbR z1)7$hEjY{8@&1z>^46ZdF935;5Pz(+2yj z%ZJ27`lpP+*%;&ZeNreAtseS>ip#Yt)Bxj@C6zlb|p+ge^YTcR=Zd-x4$Fyu4o-|rw;;mk=?=SYksHAE~J(NO{($olrb?e5gLFWbS|Vf0vrcJ zxmD>YDY9Vf0b>y$mT!(5(qY70pspA|CE8yf!S@DA{0=#}JfwF5h^*l803nqkR=O1& zF}(K6FMLSxse(!FzkT&0S3Z>pKmsTXnb4|`1|LXmXjjXi{}k8lV#6{RPS&{q#^?nh z`d1utCp6RqK08`)-?Vggt|e}$6lo@cbxBiG69Kd{x@&zJbgWX4@DQMB%dV+`wwNtL zSYI*n(aEy(O3knLzYtv$4@QK6T zf!hTD!;3tnuiw7?vcAB|Kwyn5>Ararm0$V`Y#X3OAp*m~T1Q6igPie2xlmeE6cyS$ zFCX8`>Z)|hyIWE+GA#oG_p*AQzkU0dS|Zfh=nRZtpWx2^7HSYeHG(xo%J?7|U4N|Q zyRg1)q0Y+276pVi@^tFz#1dIiW7X+`E@`rUm%AN3^xymYHt&#^CeO`eh^0)#TW=5$ zvTH_4npF~mngvC1uVNBhMZ8dKjP~CoNKb#(y^F0ICO%7DbOw0 zb(;`SkzxN8EO|i81GT32lN3IH2?$8xmiIXS1^28b>!jS>1$}&cavb1zQ0H2wrqaR7 zP-ZhHu{r5s?`lGYu&|H@4h!S-fH)$e3nk9j)-OS@&#cnrwkj32y62$*h2!!vZQKR- z)2ONt;+m&jcG2GVTGcGm7Z+J(vZ=vm-_8Z>^nd*z#^%Jh8Qi6ni?rnbUw{D*nxZdI z>a9w{d1PH@Cw8NiD{a}GZu%)ICC3c#6!RLh~0 z*suN#f`*@v)^EZI{)%qQJz?3g*kNcWWX&l6;97 z5MSTUpjy}Mpd%bs|9&4C8R4`8x6|<>ZYm9!ARvl=L6Lh+IhM?8^8gAIQfES%z(9f5$5kWc7!c|ymZTJ)sKY3Vl^SYK`+q~lq-HK4e4(bopxiYt^Z zcL3{xii+B0YgqtE3Kd5Wrkb2QU4PtGN%$+b<>&{bKc<1o7T3i9$g@pWY2_gwIqls_ETZ# z&sk(CY>z@hWW{{0{Vk3tk;@b*=JZ8p6-$H7=-`LQq+pAcmU|K(waAk797ex?-dnLC zhXF7#UHh)N4ZKaG)hr_^%8hdq-S6D2;9quC1lWiE=cf7+X_v-~{P4kGQypHTX$Sw_ zHG{8D;5`|dfAOBo{~&|?`?qlT8pJY`z%@$DX%e}%fI}hN1p#%h;CbK@ln-ng9UUF$ z6}I)~7!>X>IbhS=|7WD{u&4w*4v|<0PHJrL|J;PUdyr@zjM{altNAtBmp_8=fBg9I zNHq$#M^aSzt~6lL&b@)_=7R+z)C) z4eEA&k|2A+@$vBsz;<+1#fo`IwH;ah;eKP1l70c0%~Q(5%T-9vf$1lrAU-nk3G%Gr z;oR3hgR5zDl-S&SO_1z{o7eOd{f%B;fQu4=V7o{W z@tPc`!3ww+aAf(CT4;nWlI{6U**CANh8h{%cClcf2PyvzJ*Xqmjsb21f*~EE7Tn-qBnX}9Vjg&Z9ta6(S)EhuiUTKxv}Pew79NfkxG1myf`26#+#NdZ zJJ<2)utUcC+>jQ3d{QNGSs&Ia}kEc)B&SLYajR}$F`!J<}ovX~6n8xFQ` z#7rumxZ@eIc4(N;ukY^*8m@Si^~XysGdfm%VH zFH-32DR_J!bfMbPBj3*eqZG#$@LuxlH$7&qW)ZNXjGss-@ z(BQ@sa0Qi?m7!8HFa#GAFhQ0hVK>|WBnX)|?9=SZ3YIrem+x^XU}>RZJc9uPip1kF z;lNp$Y{f*wiA+(^Dg+eY6d(dorZ+dkAwHl^jDK?g5B)Q!V%Zk;fI;RNAWZM| zV(rb%P4qO zLXN56AAW4N*dc1?W}I3o0?6s2S9N4@_#&kkSdYH>vy+JGw9xC^@JD<72x* zU+}hfVg73>-UGE>+jtgQdMl_m9$h3MlI ZMmvKxh2^{D-zZ9{a;k3m_q;n literal 0 HcmV?d00001 diff --git a/desktop/src/app/app-routing.module.ts b/desktop/src/app/app-routing.module.ts index 1faea077b..989fc38bb 100644 --- a/desktop/src/app/app-routing.module.ts +++ b/desktop/src/app/app-routing.module.ts @@ -4,6 +4,7 @@ import { APP_CONFIG } from '../environments/environment' import { AboutComponent } from './about/about.component' import { AlignmentComponent } from './alignment/alignment.component' import { AtlasComponent } from './atlas/atlas.component' +import { CalculatorComponent } from './calculator/calculator.component' import { CalibrationComponent } from './calibration/calibration.component' import { CameraComponent } from './camera/camera.component' import { FilterWheelComponent } from './filterwheel/filterwheel.component' @@ -80,6 +81,10 @@ const routes: Routes = [ path: 'calibration', component: CalibrationComponent, }, + { + path: 'calculator', + component: CalculatorComponent, + }, { path: 'settings', component: SettingsComponent, diff --git a/desktop/src/app/app.module.ts b/desktop/src/app/app.module.ts index 46087773c..6d652fe6e 100644 --- a/desktop/src/app/app.module.ts +++ b/desktop/src/app/app.module.ts @@ -57,6 +57,8 @@ import { AlignmentComponent } from './alignment/alignment.component' import { AppRoutingModule } from './app-routing.module' import { AppComponent } from './app.component' import { AtlasComponent } from './atlas/atlas.component' +import { CalculatorComponent } from './calculator/calculator.component' +import { FormulaComponent } from './calculator/formula/formula.component' import { CalibrationComponent } from './calibration/calibration.component' import { CameraComponent } from './camera/camera.component' import { FilterWheelComponent } from './filterwheel/filterwheel.component' @@ -79,6 +81,7 @@ import { SettingsComponent } from './settings/settings.component' AnglePipe, AppComponent, AtlasComponent, + CalculatorComponent, CalibrationComponent, CameraComponent, CameraExposureComponent, @@ -90,6 +93,7 @@ import { SettingsComponent } from './settings/settings.component' FilterWheelComponent, FlatWizardComponent, FocuserComponent, + FormulaComponent, FramingComponent, GuiderComponent, HistogramComponent, diff --git a/desktop/src/app/calculator/calculator.component.html b/desktop/src/app/calculator/calculator.component.html new file mode 100644 index 000000000..3d9cd0f4a --- /dev/null +++ b/desktop/src/app/calculator/calculator.component.html @@ -0,0 +1,24 @@ +
+
+ + +
+ {{ item?.formula?.title }} +
+
+ +
+ {{ item.formula.title }} + {{ item.formula.description }} +
+
+
+
+
+ +
+
\ No newline at end of file diff --git a/desktop/src/app/calculator/calculator.component.scss b/desktop/src/app/calculator/calculator.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/desktop/src/app/calculator/calculator.component.ts b/desktop/src/app/calculator/calculator.component.ts new file mode 100644 index 000000000..84115900b --- /dev/null +++ b/desktop/src/app/calculator/calculator.component.ts @@ -0,0 +1,94 @@ +import { AfterViewInit, Component, Type } from '@angular/core' +import { ElectronService } from '../../shared/services/electron.service' +import { CalculatorFormula } from '../../shared/types/calculator.types' +import { AppComponent } from '../app.component' +import { FormulaComponent } from './formula/formula.component' + +@Component({ + selector: 'app-calculator', + templateUrl: './calculator.component.html', + styleUrls: ['./calculator.component.scss'], +}) +export class CalculatorComponent implements AfterViewInit { + + readonly formulae: { component: Type, formula: CalculatorFormula }[] = [ + { + component: FormulaComponent, + formula: { + title: 'Focal Length', + description: 'Calculate the focal length of your telescope.', + expression: 'Aperture * Focal Ratio', + operands: [ + { + label: 'Aperture', + suffix: 'mm', + }, + { + label: 'Focal Ratio', + prefix: 'f/', + }, + ], + result: { + label: 'Focal Length', + suffix: 'mm', + }, + calculate: (aperture, focalRatio) => { + if (aperture && focalRatio) { + return aperture * focalRatio + } else { + return undefined + } + }, + }, + }, + { + component: FormulaComponent, + formula: { + title: 'Focal Ratio', + description: 'Calculate the focal ratio of your telescope.', + expression: 'Focal Length / Aperture', + operands: [ + { + label: 'Focal Length', + suffix: 'mm', + }, + { + label: 'Aperture', + suffix: 'mm', + }, + ], + result: { + label: 'Focal Length', + prefix: 'f/', + }, + calculate: (focalLength, aperture) => { + if (focalLength && aperture) { + return focalLength / aperture + } else { + return undefined + } + }, + }, + }, + ] + + formula = this.formulae[0] + + private autoResizeTimeout: any + + constructor( + app: AppComponent, + private electron: ElectronService, + ) { + app.title = 'Calculator' + } + + ngAfterViewInit() { + + } + + formulaChanged() { + clearTimeout(this.autoResizeTimeout) + this.autoResizeTimeout = setTimeout(() => this.electron.autoResizeWindow(), 1000) + } +} \ No newline at end of file diff --git a/desktop/src/app/calculator/formula/formula.component.html b/desktop/src/app/calculator/formula/formula.component.html new file mode 100644 index 000000000..321f3925c --- /dev/null +++ b/desktop/src/app/calculator/formula/formula.component.html @@ -0,0 +1,35 @@ +

{{ formula.description }}

+
+ +
+
+ @for (item of formula.operands; track $index) { +
+
+ {{ item.prefix }} +
+
+ + + + +
+
+ {{ item.suffix }} +
+
+ } +
+

=

+
+ {{ formula.result.prefix }} + + + + + {{ formula.result.suffix }} +
\ No newline at end of file diff --git a/desktop/src/app/calculator/formula/formula.component.scss b/desktop/src/app/calculator/formula/formula.component.scss new file mode 100644 index 000000000..07eaf92d8 --- /dev/null +++ b/desktop/src/app/calculator/formula/formula.component.scss @@ -0,0 +1,3 @@ +:host { + width: 100%; +} \ No newline at end of file diff --git a/desktop/src/app/calculator/formula/formula.component.ts b/desktop/src/app/calculator/formula/formula.component.ts new file mode 100644 index 000000000..fac567d59 --- /dev/null +++ b/desktop/src/app/calculator/formula/formula.component.ts @@ -0,0 +1,25 @@ +import { AfterViewInit, Component, Input } from '@angular/core' +import { CalculatorFormula } from '../../../shared/types/calculator.types' + +@Component({ + selector: 'app-formula', + templateUrl: './formula.component.html', + styleUrls: ['./formula.component.scss'], +}) +export class FormulaComponent implements AfterViewInit { + + @Input({ required: true }) + readonly formula!: CalculatorFormula + + ngAfterViewInit() { + + } + + calculateFormula() { + const result = this.formula.calculate(...this.formula.operands.map(e => e.value)) + + if (result !== undefined) { + this.formula.result.value = result + } + } +} \ No newline at end of file diff --git a/desktop/src/app/home/home.component.html b/desktop/src/app/home/home.component.html index 05e1301ee..05c0a890b 100644 --- a/desktop/src/app/home/home.component.html +++ b/desktop/src/app/home/home.component.html @@ -135,6 +135,12 @@
INDI
+
+ + +
Calculator
+
+
diff --git a/desktop/src/app/home/home.component.ts b/desktop/src/app/home/home.component.ts index cb52a0641..1f8ba326e 100644 --- a/desktop/src/app/home/home.component.ts +++ b/desktop/src/app/home/home.component.ts @@ -365,6 +365,9 @@ export class HomeComponent implements AfterContentInit, OnDestroy { case 'SETTINGS': this.browserWindow.openSettings() break + case 'CALCULATOR': + this.browserWindow.openCalculator() + break case 'ABOUT': this.browserWindow.openAbout() break diff --git a/desktop/src/assets/icons/CREDITS.md b/desktop/src/assets/icons/CREDITS.md index 75b7d1cf3..ff387b1b3 100644 --- a/desktop/src/assets/icons/CREDITS.md +++ b/desktop/src/assets/icons/CREDITS.md @@ -28,4 +28,5 @@ * https://www.flaticon.com/free-icon/witch-hat_5606276 * https://www.flaticon.com/free-icon/picture_2659360 * https://www.flaticon.com/free-icon/magnifier_13113714 +* https://www.flaticon.com/free-icon/calculator_7182540 * https://thenounproject.com/icon/random-dither-4259782 diff --git a/desktop/src/assets/icons/calculator.png b/desktop/src/assets/icons/calculator.png new file mode 100644 index 0000000000000000000000000000000000000000..42eaa964dc0ba4594f315056f90ead7526dc705d GIT binary patch literal 2243 zcmV;!2t4W%5M=xX#{1>{xc5#UEI!uv z?%v(Iw!wnGv=2LTX6Br8&zw1D&b@-tAvSK?y;~rj0os6Q8Dr-HN#G1x|N7LCu5Lh= zGDws{)ZX5%F2}-MTId~>a5#tPUQwIr?CI&T{Rm|#vt5c;{dgV;5Af{|V^u#Yh0xam zHhk^rh{~mgftrfhnp?zGZKmmXPtRmAB3}zIbE!?tKj8sttweZ}ACcil;2;(k@+Vqr ztZbT^2AjIZl6KRKAwJ>|yQT<*!XUg2S!f#l=J8d47LF9!y?~=N;b@YrPen1SG(rlb z5|rpyD~EvOxWlpTB)N11A>0o@3&;1V_$r_fY%9fMd!tCH3D<-$s*Jf3{T3p1@aP_c zBl}~7!og1EKz8dm4$WUQ5n@VsbC!hH8mz31mAUOMKq`SUX4mMo4N?l~ z8Y2KqPGretEEkj3qf~HTnnVCt1 zstrQb#aXnnnh*OjNHvF{FamB|4e`;r9CmK@w?ZK}7M%R^1Xju%-L48wfc!%FvD=am`}jSc>Eg1JkSuJXb|=fDx)9KB{@=)f`%9E19Zn2qBS1 z6w|CM8m%}G5<;NNP^Ht{HQdv2z+B+IS3m_$(BQkBJ850hjA7@aUG!Y-n5O5+1-%iq>^4eECbC&udpovA1)Fr|Bd`J%H!LiHratd~ZB3MLRq-P$8i^2%M)Di07yC0*3XKP5)dx(! zrYrg0&NZ(5F{R?ZcY+TT5OiQ7p7f_l#`FHCCMR*6;ujj0ZIe#tm-BQkUV!>enqcW^ zUl^m1Ds}wba(S>#ebg z7_oQ?Y2+VwT5BvvBO^_?o|(4MsjC>uaMcL4&^u2b?)qaf`GV&HLVz+D8o7o`j~aw9 zJZqN9Fo`85i6z`sYawt6Kh-WU73j%fk3y&pR1mp|it7TNH!(hT73}s#Af(s0%Cf=x z{uHRl0zS5UDQjC+6ROHjhjZB+{pSbx;L>H+m{7=M^TzcoTGZg`8yy|v#OX6wId@Fo zOpf*BFsDU4X#^(-@j*aQ0xRI*kFVf|&wd{%z3&CTy8Ur}x&Jxd8@QO){)4B#!)I1~ zs^GkBt!vnK;Q73^jXKLum?&uMyGZe@Sir>q;so9TpIzHrP@fFiig&VPl$i_R1WmLkQubn{M`A2^t`^zxmxk+FI8U4(EIAY&Of;|Mqk0t#=UOwzCune(|dVY~S`c zjf>qkI77q396s`Tq1XOyA^hZ%IM=R`edCRw?B7CQ1xTqlaq4Z3pZJf5|ItXPkjg#% zjaZU{-GB1*heCi3Mt|CcB(r%5YZ@V`EnK> zIz)2+{)!YauTBAbV1R6IFFKjT85<+p+lzhaQblvlt9OKQeSPHm`cNxZ;tURw+_$gd zIRh&o6f${g?{3z#ta63K+Hp8PaFOSJ+r{K$e%`-veJeZKzd>z{yPhA5#pyou7tWkL zS9I;vfdlBo%)Frycnf^tbDLRLARiD?(z1FbTOZq!*RGUe&#rHI3J*|MS4-!v4umYu zmet>1vFYDLUcG&LesK6Bb}7#bPH(ZcmDk7MU}sLs1E5|1ai z5lf(^b&~> zf)>v)LI$d5l)2VKBx9WRBl1nGN*dwr3~s46uQF?{nG&@Rk<(!0E$XhbS?g{69!*({G;6 RVLbo<002ovPDHLkV1gR?M*si- literal 0 HcmV?d00001 diff --git a/desktop/src/shared/services/browser-window.service.ts b/desktop/src/shared/services/browser-window.service.ts index afe3f125e..8caf26924 100644 --- a/desktop/src/shared/services/browser-window.service.ts +++ b/desktop/src/shared/services/browser-window.service.ts @@ -108,6 +108,11 @@ export class BrowserWindowService { this.openWindow({ ...options, id: 'settings', path: 'settings', data: undefined }) } + openCalculator(options: OpenWindowOptions = {}) { + Object.assign(options, { icon: 'calculator', width: 345, height: 354 }) + this.openWindow({ ...options, id: 'calculator', path: 'calculator', data: undefined }) + } + openCalibration(options: OpenWindowOptionsWithData) { Object.assign(options, { icon: 'stack', width: 510, height: 508 }) this.openWindow({ ...options, id: 'calibration', path: 'calibration' }) diff --git a/desktop/src/shared/types/calculator.types.ts b/desktop/src/shared/types/calculator.types.ts new file mode 100644 index 000000000..012260ea0 --- /dev/null +++ b/desktop/src/shared/types/calculator.types.ts @@ -0,0 +1,19 @@ + +export interface CalculatorOperand { + label: string + prefix?: string + suffix?: string + value?: number + minFractionDigits?: number + maxFractionDigits?: number +} + +export interface CalculatorFormula { + title: string + description?: string + expression: string + operands: CalculatorOperand[] + result: CalculatorOperand + calculate: (...operands: (number | undefined)[]) => number | undefined +} + diff --git a/desktop/src/shared/types/home.types.ts b/desktop/src/shared/types/home.types.ts index 633f05f16..a85f81787 100644 --- a/desktop/src/shared/types/home.types.ts +++ b/desktop/src/shared/types/home.types.ts @@ -1,5 +1,5 @@ export type HomeWindowType = 'CAMERA' | 'MOUNT' | 'GUIDER' | 'WHEEL' | 'FOCUSER' | 'DOME' | 'ROTATOR' | 'SWITCH' | - 'SKY_ATLAS' | 'ALIGNMENT' | 'SEQUENCER' | 'IMAGE' | 'FRAMING' | 'INDI' | 'SETTINGS' | 'ABOUT' | 'FLAT_WIZARD' + 'SKY_ATLAS' | 'ALIGNMENT' | 'SEQUENCER' | 'IMAGE' | 'FRAMING' | 'INDI' | 'SETTINGS' | 'CALCULATOR' | 'ABOUT' | 'FLAT_WIZARD' export const CONNECTION_TYPES = ['INDI', 'ALPACA'] as const From 92537902033160297cf7b70cb03f9cf64b6517d9 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Tue, 27 Feb 2024 22:20:43 -0300 Subject: [PATCH 64/87] [desktop]: Add more formulae to Calculator --- desktop/src/app/app.component.ts | 2 +- desktop/src/app/app.module.ts | 2 + .../app/calculator/calculator.component.html | 4 +- .../app/calculator/calculator.component.ts | 136 ++++++++++++++++-- .../calculator/formula/formula.component.html | 11 +- .../app/filterwheel/filterwheel.component.ts | 2 +- desktop/src/app/framing/framing.component.ts | 2 +- .../shared/services/browser-window.service.ts | 2 +- desktop/src/shared/types/calculator.types.ts | 1 + 9 files changed, 141 insertions(+), 21 deletions(-) diff --git a/desktop/src/app/app.component.ts b/desktop/src/app/app.component.ts index 0e8a9e0e0..ac4ac0a00 100644 --- a/desktop/src/app/app.component.ts +++ b/desktop/src/app/app.component.ts @@ -51,7 +51,7 @@ export class AppComponent implements AfterViewInit { }) if (window.options.autoResizable !== false) { - setTimeout(() => this.electron.autoResizeWindow(), 1000) + setTimeout(() => this.electron.autoResizeWindow(), 250) } } diff --git a/desktop/src/app/app.module.ts b/desktop/src/app/app.module.ts index 6d652fe6e..f9edc46f6 100644 --- a/desktop/src/app/app.module.ts +++ b/desktop/src/app/app.module.ts @@ -23,6 +23,7 @@ import { InputSwitchModule } from 'primeng/inputswitch' import { InputTextModule } from 'primeng/inputtext' import { ListboxModule } from 'primeng/listbox' import { MenuModule } from 'primeng/menu' +import { MessageModule } from 'primeng/message' import { MultiSelectModule } from 'primeng/multiselect' import { OverlayPanelModule } from 'primeng/overlaypanel' import { ScrollPanelModule } from 'primeng/scrollpanel' @@ -138,6 +139,7 @@ import { SettingsComponent } from './settings/settings.component' InputTextModule, ListboxModule, MenuModule, + MessageModule, MultiSelectModule, OverlayPanelModule, ScrollPanelModule, diff --git a/desktop/src/app/calculator/calculator.component.html b/desktop/src/app/calculator/calculator.component.html index 3d9cd0f4a..971a39546 100644 --- a/desktop/src/app/calculator/calculator.component.html +++ b/desktop/src/app/calculator/calculator.component.html @@ -1,7 +1,7 @@
+ (ngModelChange)="formulaChanged()" [panelStyle]="{maxWidth: '100px'}">
{{ item?.formula?.title }} @@ -10,7 +10,7 @@
{{ item.formula.title }} - {{ item.formula.description }} + {{ item.formula.description }}
diff --git a/desktop/src/app/calculator/calculator.component.ts b/desktop/src/app/calculator/calculator.component.ts index 84115900b..f5f0efcb4 100644 --- a/desktop/src/app/calculator/calculator.component.ts +++ b/desktop/src/app/calculator/calculator.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, Type } from '@angular/core' +import { Component, Type } from '@angular/core' import { ElectronService } from '../../shared/services/electron.service' import { CalculatorFormula } from '../../shared/types/calculator.types' import { AppComponent } from '../app.component' @@ -9,7 +9,7 @@ import { FormulaComponent } from './formula/formula.component' templateUrl: './calculator.component.html', styleUrls: ['./calculator.component.scss'], }) -export class CalculatorComponent implements AfterViewInit { +export class CalculatorComponent { readonly formulae: { component: Type, formula: CalculatorFormula }[] = [ { @@ -35,8 +35,6 @@ export class CalculatorComponent implements AfterViewInit { calculate: (aperture, focalRatio) => { if (aperture && focalRatio) { return aperture * focalRatio - } else { - return undefined } }, }, @@ -64,8 +62,128 @@ export class CalculatorComponent implements AfterViewInit { calculate: (focalLength, aperture) => { if (focalLength && aperture) { return focalLength / aperture - } else { - return undefined + } + }, + }, + }, + { + component: FormulaComponent, + formula: { + title: 'Dawes Limit', + description: 'Calculate the maximum resolving power of your telescope using the Dawes Limit formula.', + expression: '116 / Aperture', + operands: [ + { + label: 'Aperture', + suffix: 'mm', + }, + ], + result: { + label: 'Max. Resolution', + suffix: 'arcsec', + }, + calculate: (aperture) => { + if (aperture) { + return 116 / aperture + } + }, + }, + }, + { + component: FormulaComponent, + formula: { + title: 'Rayleigh Limit', + description: 'Calculate the maximum resolving power of your telescope using the Rayleigh Limit formula.', + expression: '138 / Aperture', + operands: [ + { + label: 'Aperture', + suffix: 'mm', + }, + ], + result: { + label: 'Max. Resolution', + suffix: 'arcsec', + }, + calculate: (aperture) => { + if (aperture) { + return 138 / aperture + } + }, + }, + }, + { + component: FormulaComponent, + formula: { + title: 'Limiting Magnitude', + description: 'Calculate a telescopes approximate limiting magnitude.', + expression: '2.7 + (5 * Log(Aperture))', // 7.7 + (5 * Log(Telescope Aperture(cm))) + operands: [ + { + label: 'Aperture', + suffix: 'mm', + }, + ], + result: { + label: 'Limiting Magnitude', + }, + calculate: (aperture) => { + if (aperture) { + return 2.7 + 5 * Math.log10(aperture) + } + }, + }, + }, + { + component: FormulaComponent, + formula: { + title: 'Light Grasp Ratio', + description: 'Calculate the light grasp ratio between two telescopes.', + expression: 'Larger Aperture² / Smaller Aperture²', + operands: [ + { + label: 'Larger Aperture', + suffix: 'mm', + }, + { + label: 'Smaller Aperture', + suffix: 'mm', + }, + ], + result: { + label: 'Ratio', + }, + calculate: (larger, smaller) => { + if (larger && smaller) { + return Math.pow(larger, 2) / Math.pow(smaller, 2) + } + }, + tip: 'Compare against the human eye by putting 7 in the smaller telescope aperture box. 7mm is the aproximate maximum aperture of the human eye.' + }, + }, + { + component: FormulaComponent, + formula: { + title: 'CCD Resolution', + description: 'Calculate the resoution in arc seconds per pixel of a CCD with a particular telescope.', + expression: '(Pixel Size / Focal Length) * 206.265', + operands: [ + { + label: 'Pixel Size', + suffix: 'µm', + }, + { + label: 'Focal Length', + suffix: 'mm', + }, + ], + result: { + label: 'Resolution', + suffix: `"/pixel`, + }, + calculate: (pixelSize, focalLength) => { + if (pixelSize && focalLength) { + return pixelSize / focalLength * 206.265 } }, }, @@ -83,12 +201,8 @@ export class CalculatorComponent implements AfterViewInit { app.title = 'Calculator' } - ngAfterViewInit() { - - } - formulaChanged() { clearTimeout(this.autoResizeTimeout) - this.autoResizeTimeout = setTimeout(() => this.electron.autoResizeWindow(), 1000) + this.autoResizeTimeout = setTimeout(() => this.electron.autoResizeWindow(), 250) } } \ No newline at end of file diff --git a/desktop/src/app/calculator/formula/formula.component.html b/desktop/src/app/calculator/formula/formula.component.html index 321f3925c..e3fea9186 100644 --- a/desktop/src/app/calculator/formula/formula.component.html +++ b/desktop/src/app/calculator/formula/formula.component.html @@ -1,6 +1,6 @@

{{ formula.description }}

- +
@for (item of formula.operands; track $index) { @@ -12,7 +12,7 @@ + [showButtons]="true" styleClass="border-0 p-inputtext-sm" locale="en" />
@@ -28,8 +28,11 @@ + styleClass="border-0 p-inputtext-sm" locale="en" /> - {{ formula.result.suffix }} + {{ formula.result.suffix }} +
+
+
\ No newline at end of file diff --git a/desktop/src/app/filterwheel/filterwheel.component.ts b/desktop/src/app/filterwheel/filterwheel.component.ts index 13ddf3191..7efa7373e 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.ts +++ b/desktop/src/app/filterwheel/filterwheel.component.ts @@ -68,7 +68,7 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { this.update() if (wasConnected !== event.device.connected) { - setTimeout(() => electron.autoResizeWindow(), 1000) + setTimeout(() => electron.autoResizeWindow(), 250) } }) } diff --git a/desktop/src/app/framing/framing.component.ts b/desktop/src/app/framing/framing.component.ts index e7d8d9b70..6107e400a 100644 --- a/desktop/src/app/framing/framing.component.ts +++ b/desktop/src/app/framing/framing.component.ts @@ -81,7 +81,7 @@ export class FramingComponent implements AfterViewInit, OnDestroy { this.hipsSurvey = this.hipsSurveys[0] } - setTimeout(() => this.electron.autoResizeWindow(), 1000) + setTimeout(() => this.electron.autoResizeWindow(), 250) this.route.queryParams.subscribe(e => { const data = JSON.parse(decodeURIComponent(e.data)) as FramingData diff --git a/desktop/src/shared/services/browser-window.service.ts b/desktop/src/shared/services/browser-window.service.ts index 8caf26924..dc231be4b 100644 --- a/desktop/src/shared/services/browser-window.service.ts +++ b/desktop/src/shared/services/browser-window.service.ts @@ -109,7 +109,7 @@ export class BrowserWindowService { } openCalculator(options: OpenWindowOptions = {}) { - Object.assign(options, { icon: 'calculator', width: 345, height: 354 }) + Object.assign(options, { icon: 'calculator', width: 345, height: 340 }) this.openWindow({ ...options, id: 'calculator', path: 'calculator', data: undefined }) } diff --git a/desktop/src/shared/types/calculator.types.ts b/desktop/src/shared/types/calculator.types.ts index 012260ea0..b82d5da25 100644 --- a/desktop/src/shared/types/calculator.types.ts +++ b/desktop/src/shared/types/calculator.types.ts @@ -14,6 +14,7 @@ export interface CalculatorFormula { expression: string operands: CalculatorOperand[] result: CalculatorOperand + tip?: string calculate: (...operands: (number | undefined)[]) => number | undefined } From dc827073004aa256eee6eaf1ea4dca8c58f237ea Mon Sep 17 00:00:00 2001 From: tiagohm Date: Wed, 28 Feb 2024 21:30:02 -0300 Subject: [PATCH 65/87] [desktop]: Prefer structuredClone over Object.assign --- desktop/src/app/alignment/alignment.component.ts | 12 ++++++------ desktop/src/app/atlas/atlas.component.ts | 4 ++-- desktop/src/app/camera/camera.component.ts | 4 ++-- desktop/src/app/filterwheel/filterwheel.component.ts | 10 +++++----- desktop/src/app/flat-wizard/flat-wizard.component.ts | 6 +++--- desktop/src/app/focuser/focuser.component.ts | 2 +- desktop/src/app/home/home.component.ts | 4 ++-- desktop/src/app/mount/mount.component.ts | 4 ++-- desktop/src/app/sequencer/sequencer.component.ts | 8 ++++---- desktop/src/app/settings/settings.component.ts | 4 ++-- .../camera-exposure/camera-exposure.component.ts | 6 +++--- desktop/src/shared/services/preference.service.ts | 4 ++-- 12 files changed, 34 insertions(+), 34 deletions(-) diff --git a/desktop/src/app/alignment/alignment.component.ts b/desktop/src/app/alignment/alignment.component.ts index 201074a33..d11200c64 100644 --- a/desktop/src/app/alignment/alignment.component.ts +++ b/desktop/src/app/alignment/alignment.component.ts @@ -21,13 +21,13 @@ import { CameraComponent } from '../camera/camera.component' export class AlignmentComponent implements AfterViewInit, OnDestroy { cameras: Camera[] = [] - camera = Object.assign({}, EMPTY_CAMERA) + camera = structuredClone(EMPTY_CAMERA) mounts: Mount[] = [] - mount = Object.assign({}, EMPTY_MOUNT) + mount = structuredClone(EMPTY_MOUNT) guideOutputs: GuideOutput[] = [] - guideOutput = Object.assign({}, EMPTY_GUIDE_OUTPUT) + guideOutput = structuredClone(EMPTY_GUIDE_OUTPUT) tab = 0 @@ -40,8 +40,8 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { private id = '' readonly tppaRequest: TPPAStart = { - capture: Object.assign({}, EMPTY_CAMERA_START_CAPTURE), - plateSolver: Object.assign({}, EMPTY_PLATE_SOLVER_OPTIONS), + capture: structuredClone(EMPTY_CAMERA_START_CAPTURE), + plateSolver: structuredClone(EMPTY_PLATE_SOLVER_OPTIONS), startFromCurrentPosition: true, eastDirection: true, compensateRefraction: true, @@ -59,7 +59,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { tppaDeclination: Angle = `00°00'00"` readonly darvRequest: DARVStart = { - capture: Object.assign({}, EMPTY_CAMERA_START_CAPTURE), + capture: structuredClone(EMPTY_CAMERA_START_CAPTURE), initialPause: 5, exposureTime: 30, direction: 'NORTH', diff --git a/desktop/src/app/atlas/atlas.component.ts b/desktop/src/app/atlas/atlas.component.ts index 76a4d024e..ed1e9c9dc 100644 --- a/desktop/src/app/atlas/atlas.component.ts +++ b/desktop/src/app/atlas/atlas.component.ts @@ -80,7 +80,7 @@ export class AtlasComponent implements OnInit, AfterContentInit, AfterViewInit, refreshing = false tab = SkyAtlasTab.SUN - readonly bodyPosition = Object.assign({}, EMPTY_BODY_POSITION) + readonly bodyPosition = structuredClone(EMPTY_BODY_POSITION) moonIlluminated = 1 moonWaning = false @@ -136,7 +136,7 @@ export class AtlasComponent implements OnInit, AfterContentInit, AfterViewInit, skyObject?: DeepSkyObject skyObjectItems: DeepSkyObject[] = [] skyObjectSearchText = '' - readonly skyObjectFilter = Object.assign({}, EMPTY_SEARCH_FILTER) + readonly skyObjectFilter = structuredClone(EMPTY_SEARCH_FILTER) showSkyObjectFilter = false readonly constellationOptions: (Constellation | 'ALL')[] = ['ALL', ...CONSTELLATIONS] diff --git a/desktop/src/app/camera/camera.component.ts b/desktop/src/app/camera/camera.component.ts index eb05365a1..8ffcdb140 100644 --- a/desktop/src/app/camera/camera.component.ts +++ b/desktop/src/app/camera/camera.component.ts @@ -17,7 +17,7 @@ import { AppComponent } from '../app.component' }) export class CameraComponent implements AfterContentInit, OnDestroy { - readonly camera = Object.assign({}, EMPTY_CAMERA) + readonly camera = structuredClone(EMPTY_CAMERA) savePath = '' capturesPath = '' @@ -85,7 +85,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { exposureMode: ExposureMode = 'SINGLE' subFrame = false - readonly request = Object.assign({}, EMPTY_CAMERA_START_CAPTURE) + readonly request = structuredClone(EMPTY_CAMERA_START_CAPTURE) running = false readonly exposureModeOptions: ExposureMode[] = ['SINGLE', 'FIXED', 'LOOP'] diff --git a/desktop/src/app/filterwheel/filterwheel.component.ts b/desktop/src/app/filterwheel/filterwheel.component.ts index 7efa7373e..d0ea85152 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.ts +++ b/desktop/src/app/filterwheel/filterwheel.component.ts @@ -17,8 +17,8 @@ import { AppComponent } from '../app.component' }) export class FilterWheelComponent implements AfterContentInit, OnDestroy { - readonly wheel = Object.assign({}, EMPTY_WHEEL) - readonly request = Object.assign({}, EMPTY_CAMERA_START_CAPTURE) + readonly wheel = structuredClone(EMPTY_WHEEL) + readonly request = structuredClone(EMPTY_CAMERA_START_CAPTURE) moving = false position = 0 @@ -139,17 +139,17 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { shutterToggled(filter: FilterSlot, event: CheckboxChangeEvent) { this.filters.forEach(e => e.dark = event.checked && e === filter) - this.filterChangedPublisher.next(Object.assign({}, filter)) + this.filterChangedPublisher.next(structuredClone(filter)) } filterNameChanged(filter: FilterSlot) { if (filter.name) { - this.filterChangedPublisher.next(Object.assign({}, filter)) + this.filterChangedPublisher.next(structuredClone(filter)) } } focusOffsetChanged(filter: FilterSlot) { - this.filterChangedPublisher.next(Object.assign({}, filter)) + this.filterChangedPublisher.next(structuredClone(filter)) } private update() { diff --git a/desktop/src/app/flat-wizard/flat-wizard.component.ts b/desktop/src/app/flat-wizard/flat-wizard.component.ts index 1ae306563..71bc72924 100644 --- a/desktop/src/app/flat-wizard/flat-wizard.component.ts +++ b/desktop/src/app/flat-wizard/flat-wizard.component.ts @@ -20,10 +20,10 @@ import { CameraComponent } from '../camera/camera.component' export class FlatWizardComponent implements AfterViewInit, OnDestroy { cameras: Camera[] = [] - camera = Object.assign({}, EMPTY_CAMERA) + camera = structuredClone(EMPTY_CAMERA) wheels: FilterWheel[] = [] - wheel = Object.assign({}, EMPTY_WHEEL) + wheel = structuredClone(EMPTY_WHEEL) running = false @@ -36,7 +36,7 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { private readonly selectedFiltersMap = new Map() readonly request: FlatWizardRequest = { - captureRequest: Object.assign({}, EMPTY_CAMERA_START_CAPTURE), + captureRequest: structuredClone(EMPTY_CAMERA_START_CAPTURE), exposureMin: 1, exposureMax: 2000, meanTarget: 32768, diff --git a/desktop/src/app/focuser/focuser.component.ts b/desktop/src/app/focuser/focuser.component.ts index 2a417c774..9e7f62522 100644 --- a/desktop/src/app/focuser/focuser.component.ts +++ b/desktop/src/app/focuser/focuser.component.ts @@ -14,7 +14,7 @@ import { AppComponent } from '../app.component' }) export class FocuserComponent implements AfterViewInit, OnDestroy { - readonly focuser = Object.assign({}, EMPTY_FOCUSER) + readonly focuser = structuredClone(EMPTY_FOCUSER) moving = false position = 0 diff --git a/desktop/src/app/home/home.component.ts b/desktop/src/app/home/home.component.ts index 1f8ba326e..baaa2922f 100644 --- a/desktop/src/app/home/home.component.ts +++ b/desktop/src/app/home/home.component.ts @@ -210,12 +210,12 @@ export class HomeComponent implements AfterContentInit, OnDestroy { ngOnDestroy() { } addConnection() { - this.newConnection = [Object.assign({}, EMPTY_CONNECTION_DETAILS), undefined] + this.newConnection = [structuredClone(EMPTY_CONNECTION_DETAILS), undefined] this.showConnectionDialog = true } editConnection(connection: ConnectionDetails, event: MouseEvent) { - this.newConnection = [Object.assign({}, connection), connection] + this.newConnection = [structuredClone(connection), connection] this.showConnectionDialog = true event.stopImmediatePropagation() } diff --git a/desktop/src/app/mount/mount.component.ts b/desktop/src/app/mount/mount.component.ts index baed54123..c706074c3 100644 --- a/desktop/src/app/mount/mount.component.ts +++ b/desktop/src/app/mount/mount.component.ts @@ -29,7 +29,7 @@ export interface MountPreference { }) export class MountComponent implements AfterContentInit, OnDestroy { - readonly mount = Object.assign({}, EMPTY_MOUNT) + readonly mount = structuredClone(EMPTY_MOUNT) slewing = false parking = false @@ -57,7 +57,7 @@ export class MountComponent implements AfterContentInit, OnDestroy { targetCoordinateType: TargetCoordinateType = 'JNOW' targetRightAscension: Angle = '00h00m00s' targetDeclination: Angle = `00°00'00"` - targetComputedLocation: ComputedLocation = Object.assign({}, EMPTY_COMPUTED_LOCATION) + targetComputedLocation = structuredClone(EMPTY_COMPUTED_LOCATION) private readonly computeCoordinatePublisher = new Subject() private readonly computeTargetCoordinatePublisher = new Subject() diff --git a/desktop/src/app/sequencer/sequencer.component.ts b/desktop/src/app/sequencer/sequencer.component.ts index 01ff05843..22b1ef9a8 100644 --- a/desktop/src/app/sequencer/sequencer.component.ts +++ b/desktop/src/app/sequencer/sequencer.component.ts @@ -36,7 +36,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { focuser?: Focuser readonly captureModes: SequenceCaptureMode[] = ['FULLY', 'INTERLEAVED'] - readonly plan = Object.assign({}, EMPTY_SEQUENCE_PLAN) + readonly plan = structuredClone(EMPTY_SEQUENCE_PLAN) private entryToApply?: CameraStartCapture private entryToApplyCount: [number, number] = [0, 0] @@ -132,7 +132,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { this.savedPathWasModified = false this.storage.delete(SEQUENCER_SAVED_PATH_KEY) - Object.assign(this.plan, EMPTY_SEQUENCE_PLAN) + Object.assign(this.plan, structuredClone(EMPTY_SEQUENCE_PLAN)) this.add() }, }) @@ -321,7 +321,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { private loadPlan(plan?: SequencePlan) { plan ??= this.storage.get(SEQUENCER_PLAN_KEY, this.plan) - Object.assign(this.plan, plan) + Object.assign(this.plan, structuredClone(plan)) this.camera = this.cameras.find(e => e.name === this.plan.camera?.name) ?? this.cameras[0] this.focuser = this.focusers.find(e => e.name === this.plan.focuser?.name) ?? this.focusers[0] @@ -454,7 +454,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { } duplicateEntry(entry: CameraStartCapture, index: number) { - this.plan.entries.splice(index + 1, 0, Object.assign({}, entry)) + this.plan.entries.splice(index + 1, 0, structuredClone(entry)) } async start() { diff --git a/desktop/src/app/settings/settings.component.ts b/desktop/src/app/settings/settings.component.ts index 29a8707a4..39a13d451 100644 --- a/desktop/src/app/settings/settings.component.ts +++ b/desktop/src/app/settings/settings.component.ts @@ -20,7 +20,7 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { activeTab = 0 locations: Location[] = [] - location = Object.assign({}, EMPTY_LOCATION) + location = structuredClone(EMPTY_LOCATION) readonly plateSolverTypes = Array.from(DEFAULT_SOLVER_TYPES) plateSolverType = this.plateSolverTypes[0] @@ -61,7 +61,7 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { ngOnDestroy() { } addLocation() { - this.showLocation(Object.assign({}, EMPTY_LOCATION)) + this.showLocation(structuredClone(EMPTY_LOCATION)) } editLocation() { 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 f38932990..0115b0389 100644 --- a/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts +++ b/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts @@ -15,13 +15,13 @@ export class CameraExposureComponent { showRemainingTime: boolean = true @Input() - readonly exposure = Object.assign({}, EMPTY_CAMERA_EXPOSURE_INFO) + readonly exposure = structuredClone(EMPTY_CAMERA_EXPOSURE_INFO) @Input() - readonly capture = Object.assign({}, EMPTY_CAMERA_CAPTURE_INFO) + readonly capture = structuredClone(EMPTY_CAMERA_CAPTURE_INFO) @Input() - readonly wait = Object.assign({}, EMPTY_CAMERA_WAIT_INFO) + readonly wait = structuredClone(EMPTY_CAMERA_WAIT_INFO) handleCameraCaptureEvent(event: CameraCaptureElapsed, looping: boolean = false) { this.capture.elapsedTime = event.captureElapsedTime diff --git a/desktop/src/shared/services/preference.service.ts b/desktop/src/shared/services/preference.service.ts index 6ba740c5c..f5dd63773 100644 --- a/desktop/src/shared/services/preference.service.ts +++ b/desktop/src/shared/services/preference.service.ts @@ -38,7 +38,7 @@ export class PreferenceService { } cameraPreference(camera: Camera) { - return new PreferenceData(this.storage, `camera.${camera.name}`, () => Object.assign({}, EMPTY_CAMERA_PREFERENCE)) + return new PreferenceData(this.storage, `camera.${camera.name}`, () => structuredClone(EMPTY_CAMERA_PREFERENCE)) } cameraStartCaptureForFlatWizard(camera: Camera) { @@ -62,7 +62,7 @@ export class PreferenceService { return new PreferenceData(this.storage, key, () => EMPTY_IMAGE_PREFERENCE) } - readonly alignmentPreference = new PreferenceData(this.storage, `alignment`, () => Object.assign({}, EMPTY_ALIGNMENT_PREFERENCE)) + readonly alignmentPreference = new PreferenceData(this.storage, `alignment`, () => structuredClone(EMPTY_ALIGNMENT_PREFERENCE)) readonly connections = new PreferenceData(this.storage, 'home.connections', () => []) readonly homeImageDefaultDirectory = new PreferenceData(this.storage, 'home.image.directory', '') } \ No newline at end of file From 7027277365dad8047fd2ddbf5b0c5147988de2c5 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Wed, 28 Feb 2024 21:53:05 -0300 Subject: [PATCH 66/87] [api][desktop]: Implement Field of View on Image --- .../kotlin/nebulosa/api/image/ImageInfo.kt | 2 +- .../kotlin/nebulosa/api/image/ImageService.kt | 2 +- desktop/calculator.png | Bin 15810 -> 14284 bytes desktop/src/app/app.module.ts | 2 + desktop/src/app/image/image.component.html | 142 ++++++++++++++-- desktop/src/app/image/image.component.scss | 5 + desktop/src/app/image/image.component.ts | 155 +++++++++++++++--- .../src/shared/services/preference.service.ts | 3 +- desktop/src/shared/types/image.types.ts | 45 ++++- 9 files changed, 319 insertions(+), 37 deletions(-) diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt b/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt index d2ebe8e1c..83a6aa217 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt @@ -14,7 +14,7 @@ data class ImageInfo( val stretchShadow: Float = 0.0f, val stretchHighlight: Float = 1.0f, val stretchMidtone: Float = 0.5f, @field:JsonSerialize(using = RightAscensionSerializer::class) val rightAscension: Double? = null, @field:JsonSerialize(using = DeclinationSerializer::class) val declination: Double? = null, - val solved: Boolean = false, + val solved: ImageSolved? = null, val headers: List = emptyList(), val camera: Camera? = null, @JsonIgnoreProperties("histogram") val statistics: Statistics.Data? = null, diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt index 8a21dc3d6..35abce005 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt @@ -107,7 +107,7 @@ class ImageService( stretchParams.shadow, stretchParams.highlight, stretchParams.midtone, transformedImage.header.rightAscension.takeIf { it.isFinite() }, transformedImage.header.declination.takeIf { it.isFinite() }, - imageBucket[path]?.second != null, + imageBucket[path]?.second?.let(::ImageSolved), transformedImage.header.mapNotNull { if (it.isCommentStyle) null else ImageHeaderItem(it.key, it.value) }, instrument?.let(connectionService::camera), statistics, diff --git a/desktop/calculator.png b/desktop/calculator.png index abee956d6a13e17999aaa34d43e0b72561f24e85..306e4aba1d635c378f20128020f1477972cb1c21 100644 GIT binary patch literal 14284 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 ZMmvKxh2^{D-zZ9{a;k3m_q;n diff --git a/desktop/src/app/app.module.ts b/desktop/src/app/app.module.ts index f9edc46f6..fb4b0f37c 100644 --- a/desktop/src/app/app.module.ts +++ b/desktop/src/app/app.module.ts @@ -12,6 +12,7 @@ import { CalendarModule } from 'primeng/calendar' import { CardModule } from 'primeng/card' import { ChartModule } from 'primeng/chart' import { CheckboxModule } from 'primeng/checkbox' +import { ColorPickerModule } from 'primeng/colorpicker' import { ConfirmDialogModule } from 'primeng/confirmdialog' import { ContextMenuModule } from 'primeng/contextmenu' import { DialogModule } from 'primeng/dialog' @@ -124,6 +125,7 @@ import { SettingsComponent } from './settings/settings.component' CardModule, ChartModule, CheckboxModule, + ColorPickerModule, CommonModule, ConfirmDialogModule, ContextMenuModule, diff --git a/desktop/src/app/image/image.component.html b/desktop/src/app/image/image.component.html index 347ed8af0..e2a0b3830 100644 --- a/desktop/src/app/image/image.component.html +++ b/desktop/src/app/image/image.component.html @@ -30,6 +30,15 @@ + + @for (item of fovs; track $index) { + @if (item.enabled && item.computed) { + + } + } + +
X: {{ roiX }} Y: {{ roiY }} W: {{ roiWidth }} H: {{ roiHeight }}
@@ -175,51 +184,51 @@
- +
- +
- +
- +
+ [value]="(imageSolved.width.toFixed(2)) + ' x ' + (imageSolved.height.toFixed(2))" />
- +
- - - -
@@ -397,6 +406,119 @@
+ +
+
+ Telescope +
+
+ +
+
+ + + + +
+
+ + + + +
+
+ Camera Resolution (px) +
+
+ Camera Pixel Size (µm) +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+ +
+ + + +
+
+ @for (item of fovs; track $index) { +
+
+ +
+
+ + + + + + + + @if (item.computed) { + + + + } +
+
+ + +
+
+ } +
+
+
+
X: {{ mouseCoordinate.x }}
diff --git a/desktop/src/app/image/image.component.scss b/desktop/src/app/image/image.component.scss index 29381c256..088176a61 100644 --- a/desktop/src/app/image/image.component.scss +++ b/desktop/src/app/image/image.component.scss @@ -2,6 +2,11 @@ width: 100vw; height: 100vh; display: block; + + svg.fov { + fill: transparent; + stroke-width: 0.25rem; + } } .roi { diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 6cb535f12..f3fb584b4 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -18,7 +18,7 @@ import { PrimeService } from '../../shared/services/prime.service' import { CheckableMenuItem, ToggleableMenuItem } from '../../shared/types/app.types' import { Angle, AstronomicalObject, DeepSkyObject, EquatorialCoordinateJ2000, Star } from '../../shared/types/atlas.types' import { Camera } from '../../shared/types/camera.types' -import { DetectedStar, EMPTY_IMAGE_SOLVED, FITSHeaderItem, ImageAnnotation, ImageChannel, ImageData, ImageInfo, ImagePreference, ImageStatisticsBitOption, SCNRProtectionMethod, SCNR_PROTECTION_METHODS } from '../../shared/types/image.types' +import { DEFAULT_FOV, DetectedStar, EMPTY_IMAGE_SOLVED, FITSHeaderItem, FOV, ImageAnnotation, ImageChannel, ImageData, ImageInfo, ImagePreference, ImageSolved, ImageStatisticsBitOption, SCNRProtectionMethod, SCNR_PROTECTION_METHODS } from '../../shared/types/image.types' import { Mount } from '../../shared/types/mount.types' import { DEFAULT_SOLVER_TYPES } from '../../shared/types/settings.types' import { CoordinateInterpolator, InterpolatedCoordinate } from '../../shared/utils/coordinate-interpolation' @@ -74,10 +74,10 @@ export class ImageComponent implements AfterViewInit, OnDestroy { solving = false solved = false solverBlind = true - solverCenterRA = '' - solverCenterDEC = '' + solverCenterRA: Angle = '' + solverCenterDEC: Angle = '' solverRadius = 4 - readonly solvedData = Object.assign({}, EMPTY_IMAGE_SOLVED) + readonly imageSolved = structuredClone(EMPTY_IMAGE_SOLVED) readonly solverTypes = Array.from(DEFAULT_SOLVER_TYPES) solverType = this.solverTypes[0] @@ -109,6 +109,11 @@ export class ImageComponent implements AfterViewInit, OnDestroy { statisticsBitLength = this.statisticsBitOptions[0] imageInfo?: ImageInfo + showFOVDialog = false + readonly fov = structuredClone(DEFAULT_FOV) + fovs: FOV[] = [] + editedFOV?: FOV + private panZoom?: PanZoom private imageURL!: string private imageMouseX = 0 @@ -235,6 +240,19 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } + private readonly frameAtThisCoordinateMenuItem: MenuItem = { + label: 'Frame at this coordinate', + icon: 'mdi mdi-image', + disabled: true, + command: () => { + const coordinate = this.mouseCoordinateInterpolation?.interpolateAsText(this.imageMouseX, this.imageMouseY, false, false, false) + + if (coordinate) { + this.browserWindow.openFraming({ data: { rightAscension: coordinate.alpha, declination: coordinate.delta } }) + } + }, + } + private readonly crosshairMenuItem: CheckableMenuItem = { label: 'Crosshair', icon: 'mdi mdi-bullseye', @@ -317,6 +335,18 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } + private readonly fovMenuItem: MenuItem = { + label: 'Field of View', + icon: 'mdi mdi-camera-metering-spot', + command: () => { + this.showFOVDialog = !this.showFOVDialog + + if (this.showFOVDialog) { + this.fovs.forEach(e => this.computeFOV(e)) + } + }, + } + private readonly overlayMenuItem: MenuItem = { label: 'Overlay', icon: 'mdi mdi-layers', @@ -325,6 +355,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.annotationMenuItem, this.detectStarsMenuItem, this.roiMenuItem, + this.fovMenuItem, ] } @@ -346,6 +377,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.fitsHeaderMenuItem, SEPARATOR_MENU_ITEM, this.pointMountHereMenuItem, + this.frameAtThisCoordinateMenuItem, ] mouseCoordinate?: InterpolatedCoordinate & Partial<{ x: number, y: number }> @@ -494,7 +526,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.detectedStarsIsVisible = false this.detectStarsMenuItem.toggleable = false - Object.assign(this.solvedData, EMPTY_IMAGE_SOLVED) + Object.assign(this.imageSolved, EMPTY_IMAGE_SOLVED) this.histogram?.update([]) } @@ -545,8 +577,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.stretchMidtone = Math.trunc(info.stretchMidtone * 65536) } - this.annotationMenuItem.disabled = !info.solved - this.pointMountHereMenuItem.disabled = !info.solved + this.updateImageSolved(info.solved) + this.fitsHeaders = info.headers if (this.imageURL) window.URL.revokeObjectURL(this.imageURL) @@ -692,27 +724,31 @@ export class ImageComponent implements AfterViewInit, OnDestroy { try { const options = this.preference.plateSolverOptions(this.solverType).get() - - Object.assign(this.solvedData, - await this.api.solveImage(options, this.imageData.path!, this.solverBlind, - this.solverCenterRA, this.solverCenterDEC, this.solverRadius)) + const solved = await this.api.solveImage(options, this.imageData.path!, this.solverBlind, + this.solverCenterRA, this.solverCenterDEC, this.solverRadius) this.savePreference() - - this.solved = true - this.annotationMenuItem.disabled = false - this.pointMountHereMenuItem.disabled = false + this.updateImageSolved(solved) } catch { - this.solved = false - Object.assign(this.solvedData, EMPTY_IMAGE_SOLVED) - this.annotationMenuItem.disabled = true - this.pointMountHereMenuItem.disabled = true + this.updateImageSolved(this.imageInfo?.solved) } finally { this.solving = false this.retrieveCoordinateInterpolation() } } + private updateImageSolved(solved?: ImageSolved) { + this.solved = !!solved + Object.assign(this.imageSolved, solved ?? EMPTY_IMAGE_SOLVED) + this.annotationMenuItem.disabled = !this.solved + this.fovMenuItem.disabled = !this.solved + this.pointMountHereMenuItem.disabled = !this.solved + this.frameAtThisCoordinateMenuItem.disabled = !this.solved + + if (solved) this.fovs.forEach(e => this.computeFOV(e)) + else this.fovs.forEach(e => e.computed = undefined) + } + mountSync(coordinate: EquatorialCoordinateJ2000) { this.executeMount((mount) => { this.api.mountSync(mount, coordinate.rightAscensionJ2000, coordinate.declinationJ2000, true) @@ -732,7 +768,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } frame(coordinate: EquatorialCoordinateJ2000) { - this.browserWindow.openFraming({ data: { rightAscension: coordinate.rightAscensionJ2000, declination: coordinate.declinationJ2000, fov: this.solvedData!.width / 60, rotation: this.solvedData!.orientation } }) + this.browserWindow.openFraming({ data: { rightAscension: coordinate.rightAscensionJ2000, declination: coordinate.declinationJ2000, fov: this.imageSolved!.width / 60, rotation: this.imageSolved!.orientation } }) } imageLoaded() { @@ -755,10 +791,89 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } } + addFOV() { + if (this.computeFOV(this.fov)) { + this.fovs.push(structuredClone(this.fov)) + this.preference.imageFOVs.set(this.fovs) + } + } + + editFOV(fov: FOV) { + Object.assign(this.fov, structuredClone(fov)) + this.editedFOV = fov + } + + cancelEditFOV() { + this.editedFOV = undefined + } + + saveFOV() { + if (this.editedFOV && this.computeFOV(this.fov)) { + Object.assign(this.editedFOV, structuredClone(this.fov)) + this.preference.imageFOVs.set(this.fovs) + this.editedFOV = undefined + } + } + + private computeFOV(fov: FOV) { + if (this.imageInfo && this.imageSolved.scale > 0) { + const focalLength = fov.focalLength * (fov.barlowReducer || 1) + + const resolution = { + width: fov.pixelSize.width / focalLength * 206.265, // arcsec/pixel + height: fov.pixelSize.height / focalLength * 206.265, // arcsec/pixel + } + + const svg = { + x: this.imageInfo.width / 2, + y: this.imageInfo.height / 2, + width: fov.cameraSize.width * (resolution.width / this.imageSolved.scale), + height: fov.cameraSize.height * (resolution.height / this.imageSolved.scale), + } + + svg.x += (this.imageInfo.width - svg.width) / 2 + svg.y += (this.imageInfo.height - svg.height) / 2 + + fov.computed = { + cameraResolution: { + width: resolution.width * fov.bin, + height: resolution.height * fov.bin, + }, + focalRatio: focalLength / fov.aperture, + fieldSize: { + width: resolution.width * fov.cameraSize.width / 3600, // deg + height: resolution.height * fov.cameraSize.height / 3600, // deg + }, + svg, + } + + console.info(fov.computed) + + return true + } else { + return false + } + } + + deleteFOV(fov: FOV) { + const index = this.fovs.indexOf(fov) + + if (index >= 0) { + if (this.fovs[index] === this.editedFOV) { + this.editedFOV = undefined + } + + this.fovs.splice(index, 1) + this.preference.imageFOVs.set(this.fovs) + } + } + private loadPreference(camera?: Camera) { const preference = this.preference.imagePreference(camera).get() this.solverRadius = preference.solverRadius ?? this.solverRadius this.solverType = preference.solverType ?? this.solverTypes[0] + this.fovs = this.preference.imageFOVs.get() + this.fovs.forEach(e => { e.enabled = false; e.computed = undefined }) } private savePreference() { diff --git a/desktop/src/shared/services/preference.service.ts b/desktop/src/shared/services/preference.service.ts index f5dd63773..7b7be1424 100644 --- a/desktop/src/shared/services/preference.service.ts +++ b/desktop/src/shared/services/preference.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core' import { AlignmentPreference, EMPTY_ALIGNMENT_PREFERENCE } from '../types/alignment.types' import { Camera, CameraPreference, CameraStartCapture, EMPTY_CAMERA_PREFERENCE } from '../types/camera.types' import { ConnectionDetails } from '../types/home.types' -import { EMPTY_IMAGE_PREFERENCE, ImagePreference } from '../types/image.types' +import { EMPTY_IMAGE_PREFERENCE, FOV, ImagePreference } from '../types/image.types' import { EMPTY_PLATE_SOLVER_OPTIONS, PlateSolverOptions, PlateSolverType } from '../types/settings.types' import { FilterWheel, WheelPreference } from '../types/wheel.types' import { LocalStorageService } from './local-storage.service' @@ -65,4 +65,5 @@ export class PreferenceService { readonly alignmentPreference = new PreferenceData(this.storage, `alignment`, () => structuredClone(EMPTY_ALIGNMENT_PREFERENCE)) readonly connections = new PreferenceData(this.storage, 'home.connections', () => []) readonly homeImageDefaultDirectory = new PreferenceData(this.storage, 'home.image.directory', '') + readonly imageFOVs = new PreferenceData(this.storage, 'image.fovs', () => []) } \ No newline at end of file diff --git a/desktop/src/shared/types/image.types.ts b/desktop/src/shared/types/image.types.ts index 999069021..4d30bf254 100644 --- a/desktop/src/shared/types/image.types.ts +++ b/desktop/src/shared/types/image.types.ts @@ -1,4 +1,5 @@ -import { AstronomicalObject, DeepSkyObject, EquatorialCoordinateJ2000, Star } from './atlas.types' +import { Point, Size } from 'electron' +import { Angle, AstronomicalObject, DeepSkyObject, EquatorialCoordinateJ2000, Star } from './atlas.types' import { Camera } from './camera.types' import { PlateSolverType } from './settings.types' @@ -23,9 +24,9 @@ export interface ImageInfo { stretchShadow: number stretchHighlight: number stretchMidtone: number - rightAscension?: string - declination?: string - solved: boolean + rightAscension?: Angle + declination?: Angle + solved?: ImageSolved headers: FITSHeaderItem[] statistics: ImageStatistics } @@ -110,3 +111,39 @@ export interface ImageData { source?: ImageSource title?: string } + +export interface FOV { + enabled: boolean + focalLength: number + aperture: number + cameraSize: Size + pixelSize: Size + barlowReducer: number + bin: number + rotation: number + color: string + computed?: { + cameraResolution: Size + focalRatio: number + fieldSize: Size + svg: Size & Point + } +} + +export const DEFAULT_FOV: FOV = { + enabled: true, + focalLength: 600, + aperture: 80, + cameraSize: { + width: 1392, + height: 1040, + }, + pixelSize: { + width: 6.45, + height: 6.45, + }, + barlowReducer: 1, + bin: 1, + rotation: 0, + color: '#FFFF00', +} From 70f1d4905f95ff128974ac0fb3b1140e4807d782 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Wed, 28 Feb 2024 22:01:45 -0300 Subject: [PATCH 67/87] [desktop]: Fix double load image on Framing --- desktop/src/app/image/image.component.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index f3fb584b4..b0ff4becc 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -504,6 +504,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { if (data.source === 'FRAMING') { this.disableAutoStretch() + this.resetStretch(false) } else if (data.source === 'FLAT_WIZARD') { this.disableCalibrate(false) } @@ -649,11 +650,13 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.loadImage() } - resetStretch() { + resetStretch(load: boolean = true) { this.stretchShadowHighlight = [0, 65536] this.stretchMidtone = 32768 - this.stretchImage() + if (load) { + this.stretchImage() + } } toggleStretch() { @@ -669,7 +672,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { stretchImage() { this.disableAutoStretch() - this.loadImage() + return this.loadImage() } invertImage() { From f82c0f4c5d115a658190185f416626f921f9cfa0 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Thu, 29 Feb 2024 14:26:50 -0300 Subject: [PATCH 68/87] [api]: Fix Simbad Database --- .../test/kotlin/SimbadDatabaseGenerator.kt | 25 +++++++++++-------- .../resources/{bennett.csv => BENNETT.csv} | 0 .../resources/{caldwell.csv => CALDWELL.csv} | 0 .../test/resources/{dunlop.csv => DUNLOP.csv} | 0 .../resources/{hershel.csv => HERSHEL.csv} | 0 .../resources/{melotte.csv => MELOTTE.csv} | 0 .../nebulosa/skycatalog/stellarium/Nebula.kt | 14 +++++------ .../src/test/kotlin/NebulaTest.kt | 21 ++++++++++------ 8 files changed, 35 insertions(+), 25 deletions(-) rename api/src/test/resources/{bennett.csv => BENNETT.csv} (100%) rename api/src/test/resources/{caldwell.csv => CALDWELL.csv} (100%) rename api/src/test/resources/{dunlop.csv => DUNLOP.csv} (100%) rename api/src/test/resources/{hershel.csv => HERSHEL.csv} (100%) rename api/src/test/resources/{melotte.csv => MELOTTE.csv} (100%) diff --git a/api/src/test/kotlin/SimbadDatabaseGenerator.kt b/api/src/test/kotlin/SimbadDatabaseGenerator.kt index 5c1584327..c960c968d 100644 --- a/api/src/test/kotlin/SimbadDatabaseGenerator.kt +++ b/api/src/test/kotlin/SimbadDatabaseGenerator.kt @@ -18,10 +18,7 @@ import okio.source import org.slf4j.LoggerFactory import java.io.InputStreamReader import java.nio.file.Path -import java.util.concurrent.Callable -import java.util.concurrent.Executors -import java.util.concurrent.Future -import java.util.concurrent.TimeUnit +import java.util.concurrent.* import kotlin.io.path.createDirectories import kotlin.io.path.deleteRecursively import kotlin.math.min @@ -47,31 +44,31 @@ object SimbadDatabaseGenerator { .commentCharacter('#') .commentStrategy(CommentStrategy.SKIP) - @JvmStatic private val MELOTTE = resource("melotte.csv")!! + @JvmStatic private val MELOTTE = resource("MELOTTE.csv")!! .use { stream -> CSV_READER.ofCsvRecord(InputStreamReader(stream, Charsets.UTF_8)) .associate { it.getField(1) to it.getField(0) } } - @JvmStatic private val CALDWELL = resource("caldwell.csv")!! + @JvmStatic private val CALDWELL = resource("CALDWELL.csv")!! .use { stream -> CSV_READER.ofCsvRecord(InputStreamReader(stream, Charsets.UTF_8)) .associate { it.getField(1).ifEmpty { it.getField(2) } to it.getField(0) } } - @JvmStatic private val BENNETT = resource("bennett.csv")!! + @JvmStatic private val BENNETT = resource("BENNETT.csv")!! .use { stream -> CSV_READER.ofCsvRecord(InputStreamReader(stream, Charsets.UTF_8)) .associate { it.getField(1) to it.getField(0) } } - @JvmStatic private val DUNLOP = resource("dunlop.csv")!! + @JvmStatic private val DUNLOP = resource("DUNLOP.csv")!! .use { stream -> CSV_READER.ofCsvRecord(InputStreamReader(stream, Charsets.UTF_8)) .associate { it.getField(1) to it.getField(0) } } - @JvmStatic private val HERSHEL = resource("hershel.csv")!! + @JvmStatic private val HERSHEL = resource("HERSHEL.csv")!! .use { stream -> CSV_READER.ofCsvRecord(InputStreamReader(stream, Charsets.UTF_8)) .associate { it.getField(1) to it.getField(0) } @@ -102,7 +99,8 @@ object SimbadDatabaseGenerator { @JvmStatic private val MAG_H = FLUX_TABLE.column("H") @JvmStatic private val MAG_K = FLUX_TABLE.column("K") - @JvmStatic private val STELLARIUM_NAMES = Nebula.namesFor(Path.of("data", "names.dat").source()).toMutableList().also { it.reverse() } + @JvmStatic private val STELLARIUM_NAMES = Path.of("data", "names.dat").source().use(Nebula::namesFor).toMutableList() + @JvmStatic private val ENTITY_IDS = ConcurrentHashMap.newKeySet(64000) @JvmStatic fun SimbadEntity.generateNames(): Boolean { @@ -274,6 +272,7 @@ object SimbadDatabaseGenerator { if (entity.generateNames()) { entities.add(entity) writeCount++ + ENTITY_IDS.add(entity.id) } } @@ -330,6 +329,7 @@ object SimbadDatabaseGenerator { try { val rows = SIMBAD_SERVICE.query(query).execute().body().takeIf { !it.isNullOrEmpty() } ?: return entities ids = LongArray(rows.size) { rows[it].getField("oidref").toLong() } + ids = ids.filter { it !in ENTITY_IDS }.toLongArray() break } catch (e: Throwable) { log.error("Failed to retrieve IDs. attempt=${attempt++}, query=$query", e) @@ -338,7 +338,10 @@ object SimbadDatabaseGenerator { } } - if (ids.isEmpty()) break + if (ids.isEmpty()) { + log.info("no IDs") + break + } lastID = ids.last() diff --git a/api/src/test/resources/bennett.csv b/api/src/test/resources/BENNETT.csv similarity index 100% rename from api/src/test/resources/bennett.csv rename to api/src/test/resources/BENNETT.csv diff --git a/api/src/test/resources/caldwell.csv b/api/src/test/resources/CALDWELL.csv similarity index 100% rename from api/src/test/resources/caldwell.csv rename to api/src/test/resources/CALDWELL.csv diff --git a/api/src/test/resources/dunlop.csv b/api/src/test/resources/DUNLOP.csv similarity index 100% rename from api/src/test/resources/dunlop.csv rename to api/src/test/resources/DUNLOP.csv diff --git a/api/src/test/resources/hershel.csv b/api/src/test/resources/HERSHEL.csv similarity index 100% rename from api/src/test/resources/hershel.csv rename to api/src/test/resources/HERSHEL.csv diff --git a/api/src/test/resources/melotte.csv b/api/src/test/resources/MELOTTE.csv similarity index 100% rename from api/src/test/resources/melotte.csv rename to api/src/test/resources/MELOTTE.csv diff --git a/nebulosa-skycatalog-stellarium/src/main/kotlin/nebulosa/skycatalog/stellarium/Nebula.kt b/nebulosa-skycatalog-stellarium/src/main/kotlin/nebulosa/skycatalog/stellarium/Nebula.kt index 039d8d543..db16bb05e 100644 --- a/nebulosa-skycatalog-stellarium/src/main/kotlin/nebulosa/skycatalog/stellarium/Nebula.kt +++ b/nebulosa-skycatalog-stellarium/src/main/kotlin/nebulosa/skycatalog/stellarium/Nebula.kt @@ -85,10 +85,10 @@ class Nebula : SkyCatalog(94661) { if (ngc > 0) "NGC $ngc".findNames() if (ic > 0) "IC $ic".findNames() if (m > 0) "M $m".findNames() - if (mel > 0) "Mellote $mel".findNames() - if (b > 0) "Barnard $b".findNames() - if (c > 0) "Caldwell $c".findNames() - if (cr > 0) "Collinder $cr".findNames() + if (mel > 0) "Mel $mel".findNames() + if (b > 0) "B $b".findNames() + if (c > 0) "C $c".findNames() + if (cr > 0) "Cr $cr".findNames() if (ced.isNotEmpty()) "CED $ced".findNames() if (sh2 > 0) "SH 2-$sh2".findNames() if (rcw > 0) "RCW $rcw".findNames() @@ -104,12 +104,12 @@ class Nebula : SkyCatalog(94661) { if (eso.isNotEmpty()) "ESO $eso".findNames() if (snrg.isNotEmpty()) "SNRG $snrg".findNames() if (dwb > 0) "DWB $dwb".findNames() - if (st > 0) "Stock $st".findNames() + if (st > 0) "St $st".findNames() if (ldn > 0) "LDN $ldn".findNames() if (hcg.isNotEmpty()) "HCG $hcg".findNames() if (vdbh.isNotEmpty()) "VdBH $vdbh".findNames() - if (tr > 0) "Trumpler $tr".findNames() - if (ru > 0) "Ruprecht $ru".findNames() + if (tr > 0) "Tr $tr".findNames() + if (ru > 0) "Ru $ru".findNames() if (vdbha > 0) "VdBHA $vdbha".findNames() val nebula = NebulaEntry( diff --git a/nebulosa-skycatalog-stellarium/src/test/kotlin/NebulaTest.kt b/nebulosa-skycatalog-stellarium/src/test/kotlin/NebulaTest.kt index 4840439ac..ad244ed64 100644 --- a/nebulosa-skycatalog-stellarium/src/test/kotlin/NebulaTest.kt +++ b/nebulosa-skycatalog-stellarium/src/test/kotlin/NebulaTest.kt @@ -1,5 +1,7 @@ import io.kotest.core.annotation.EnabledIf import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.ints.shouldBeExactly import nebulosa.math.deg import nebulosa.math.hours @@ -13,15 +15,15 @@ import java.nio.file.Path class NebulaTest : StringSpec() { init { - val nebula = Nebula() - val catalog = Path.of("../data/catalog.dat").source().gzip() - val names = Path.of("../data/names.dat").source() - nebula.load(catalog, names) - "load" { + val catalog = Path.of("../data/catalog.dat").source().gzip() + val names = Path.of("../data/names.dat").source() + + val nebula = Nebula() + nebula.load(catalog, names) + nebula.size shouldBeExactly 94661 - } - "search around" { + nebula .searchAround("05 35 16.8".hours, "-05 23 24".deg, 1.0.deg) .onEach { println(it) } @@ -32,5 +34,10 @@ class NebulaTest : StringSpec() { .onEach { println(it) } .size shouldBeExactly 19 } + "names" { + val names = Path.of("../data/names.dat").source().use(Nebula::namesFor) + val thorHelmet = names.filter { it.id == "NGC 2359" } shouldHaveSize 5 + thorHelmet.map { it.name }.shouldContainAll("Thor's Helmet", "Duck Head Nebula", "Flying Eye Nebula", "Duck Nebula", "Whistle Nebula") + } } } From 2de3db1261facd9c69ce74c5024b21a7300aef4b Mon Sep 17 00:00:00 2001 From: tiagohm Date: Thu, 29 Feb 2024 14:37:23 -0300 Subject: [PATCH 69/87] [desktop]: Fix auto resize window timeout --- desktop/src/app/app.component.ts | 2 +- desktop/src/app/calculator/calculator.component.ts | 2 +- desktop/src/app/framing/framing.component.ts | 2 +- desktop/src/shared/services/electron.service.ts | 12 ++++++++---- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/desktop/src/app/app.component.ts b/desktop/src/app/app.component.ts index ac4ac0a00..c62ee9d04 100644 --- a/desktop/src/app/app.component.ts +++ b/desktop/src/app/app.component.ts @@ -51,7 +51,7 @@ export class AppComponent implements AfterViewInit { }) if (window.options.autoResizable !== false) { - setTimeout(() => this.electron.autoResizeWindow(), 250) + this.electron.autoResizeWindow() } } diff --git a/desktop/src/app/calculator/calculator.component.ts b/desktop/src/app/calculator/calculator.component.ts index f5f0efcb4..d67c42912 100644 --- a/desktop/src/app/calculator/calculator.component.ts +++ b/desktop/src/app/calculator/calculator.component.ts @@ -203,6 +203,6 @@ export class CalculatorComponent { formulaChanged() { clearTimeout(this.autoResizeTimeout) - this.autoResizeTimeout = setTimeout(() => this.electron.autoResizeWindow(), 250) + this.autoResizeTimeout = this.electron.autoResizeWindow() } } \ No newline at end of file diff --git a/desktop/src/app/framing/framing.component.ts b/desktop/src/app/framing/framing.component.ts index 6107e400a..dde107d66 100644 --- a/desktop/src/app/framing/framing.component.ts +++ b/desktop/src/app/framing/framing.component.ts @@ -81,7 +81,7 @@ export class FramingComponent implements AfterViewInit, OnDestroy { this.hipsSurvey = this.hipsSurveys[0] } - setTimeout(() => this.electron.autoResizeWindow(), 250) + this.electron.autoResizeWindow() this.route.queryParams.subscribe(e => { const data = JSON.parse(decodeURIComponent(e.data)) as FramingData diff --git a/desktop/src/shared/services/electron.service.ts b/desktop/src/shared/services/electron.service.ts index a26798b59..4de8889f7 100644 --- a/desktop/src/shared/services/electron.service.ts +++ b/desktop/src/shared/services/electron.service.ts @@ -161,11 +161,15 @@ export class ElectronService { this.send('WINDOW.RESIZE', Math.floor(size)) } - autoResizeWindow() { - const size = document.getElementsByTagName('app-root')[0]?.getBoundingClientRect()?.height + autoResizeWindow(timeout: number = 500): any { + if (timeout <= 0) { + const size = document.getElementsByTagName('app-root')[0]?.getBoundingClientRect()?.height - if (size > 0) { - this.resizeWindow(size) + if (size > 0) { + this.resizeWindow(size) + } + } else { + return setTimeout(() => this.autoResizeWindow(), timeout) } } From 8753e0768c67ac9c70f4b27040fac313c6ab49ec Mon Sep 17 00:00:00 2001 From: tiagohm Date: Thu, 29 Feb 2024 17:26:00 -0300 Subject: [PATCH 70/87] [desktop]: Fix load image FOVs --- desktop/src/app/image/image.component.ts | 9 +++------ desktop/src/shared/services/preference.service.ts | 6 +----- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index b0ff4becc..13236c505 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -17,7 +17,6 @@ import { PreferenceService } from '../../shared/services/preference.service' import { PrimeService } from '../../shared/services/prime.service' import { CheckableMenuItem, ToggleableMenuItem } from '../../shared/types/app.types' import { Angle, AstronomicalObject, DeepSkyObject, EquatorialCoordinateJ2000, Star } from '../../shared/types/atlas.types' -import { Camera } from '../../shared/types/camera.types' import { DEFAULT_FOV, DetectedStar, EMPTY_IMAGE_SOLVED, FITSHeaderItem, FOV, ImageAnnotation, ImageChannel, ImageData, ImageInfo, ImagePreference, ImageSolved, ImageStatisticsBitOption, SCNRProtectionMethod, SCNR_PROTECTION_METHODS } from '../../shared/types/image.types' import { Mount } from '../../shared/types/mount.types' import { DEFAULT_SOLVER_TYPES } from '../../shared/types/settings.types' @@ -546,8 +545,6 @@ export class ImageComponent implements AfterViewInit, OnDestroy { await this.loadImageFromPath(this.imageData.path) } - this.loadPreference(this.imageData.camera) - if (this.imageData.title) { this.app.subTitle = this.imageData.title } else if (this.imageData.camera) { @@ -871,8 +868,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } } - private loadPreference(camera?: Camera) { - const preference = this.preference.imagePreference(camera).get() + private loadPreference() { + const preference = this.preference.imagePreference.get() this.solverRadius = preference.solverRadius ?? this.solverRadius this.solverType = preference.solverType ?? this.solverTypes[0] this.fovs = this.preference.imageFOVs.get() @@ -885,7 +882,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { solverType: this.solverType } - this.preference.imagePreference(this.imageData.camera).set(preference) + this.preference.imagePreference.set(preference) } private async executeMount(action: (mount: Mount) => void) { diff --git a/desktop/src/shared/services/preference.service.ts b/desktop/src/shared/services/preference.service.ts index 7b7be1424..94785897e 100644 --- a/desktop/src/shared/services/preference.service.ts +++ b/desktop/src/shared/services/preference.service.ts @@ -57,11 +57,7 @@ export class PreferenceService { return new PreferenceData(this.storage, `settings.plateSolver.${type}`, () => { ...EMPTY_PLATE_SOLVER_OPTIONS, type }) } - imagePreference(camera?: Camera) { - const key = camera ? `image.${camera.name}` : 'image' - return new PreferenceData(this.storage, key, () => EMPTY_IMAGE_PREFERENCE) - } - + readonly imagePreference = new PreferenceData(this.storage, 'image', () => structuredClone(EMPTY_IMAGE_PREFERENCE)) readonly alignmentPreference = new PreferenceData(this.storage, `alignment`, () => structuredClone(EMPTY_ALIGNMENT_PREFERENCE)) readonly connections = new PreferenceData(this.storage, 'home.connections', () => []) readonly homeImageDefaultDirectory = new PreferenceData(this.storage, 'home.image.directory', '') From f616dc74bffa1fd1eb0911e0d7ac1dc44e01c40e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:39:32 +0000 Subject: [PATCH 71/87] [api]: Bump com.github.oshi:oshi-core from 6.4.11 to 6.4.13 Bumps [com.github.oshi:oshi-core](https://github.com/oshi/oshi) from 6.4.11 to 6.4.13. - [Release notes](https://github.com/oshi/oshi/releases) - [Changelog](https://github.com/oshi/oshi/blob/master/CHANGELOG.md) - [Commits](https://github.com/oshi/oshi/compare/oshi-parent-6.4.11...oshi-parent-6.4.13) --- updated-dependencies: - dependency-name: com.github.oshi:oshi-core dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 2b677f50a..c25615be3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,7 +30,7 @@ dependencyResolutionManagement { library("apache-lang3", "org.apache.commons:commons-lang3:3.14.0") library("apache-codec", "commons-codec:commons-codec:1.16.0") library("apache-collections", "org.apache.commons:commons-collections4:4.4") - library("oshi", "com.github.oshi:oshi-core:6.4.11") + library("oshi", "com.github.oshi:oshi-core:6.4.13") library("timeshape", "net.iakovlev:timeshape:2022g.17") library("jna", "net.java.dev.jna:jna:5.14.0") library("kotest-assertions-core", "io.kotest:kotest-assertions-core:5.8.0") From 092a0923ade8fe4612adb55b498e84b756517bd2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:39:25 +0000 Subject: [PATCH 72/87] [api]: Bump org.springframework:spring-context-indexer Bumps [org.springframework:spring-context-indexer](https://github.com/spring-projects/spring-framework) from 6.1.3 to 6.1.4. - [Release notes](https://github.com/spring-projects/spring-framework/releases) - [Commits](https://github.com/spring-projects/spring-framework/compare/v6.1.3...v6.1.4) --- updated-dependencies: - dependency-name: org.springframework:spring-context-indexer dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- api/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/build.gradle.kts b/api/build.gradle.kts index be9e07f65..ad7e2dfd2 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -45,7 +45,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-undertow") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") - kapt("org.springframework:spring-context-indexer:6.1.3") + kapt("org.springframework:spring-context-indexer:6.1.4") testImplementation(project(":nebulosa-skycatalog-stellarium")) testImplementation(project(":nebulosa-test")) } From 1a026d29a3382463b3d19503e0d7641300f4d337 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:39:17 +0000 Subject: [PATCH 73/87] [api]: Bump com.squareup.okio:okio from 3.7.0 to 3.8.0 Bumps [com.squareup.okio:okio](https://github.com/square/okio) from 3.7.0 to 3.8.0. - [Changelog](https://github.com/square/okio/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okio/compare/parent-3.7.0...parent-3.8.0) --- updated-dependencies: - dependency-name: com.squareup.okio:okio dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index c25615be3..e14b03ed4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,7 +12,7 @@ buildCache { dependencyResolutionManagement { versionCatalogs { create("libs") { - library("okio", "com.squareup.okio:okio:3.7.0") + library("okio", "com.squareup.okio:okio:3.8.0") library("okhttp", "com.squareup.okhttp3:okhttp:4.12.0") library("okhttp-logging", "com.squareup.okhttp3:logging-interceptor:4.12.0") library("jackson-core", "com.fasterxml.jackson.core:jackson-databind:2.16.1") From 28c31809130e94f0ea37876c9f72afb76d36fe6d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:39:13 +0000 Subject: [PATCH 74/87] [api]: Bump ch.qos.logback:logback-classic from 1.4.14 to 1.5.1 Bumps [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) from 1.4.14 to 1.5.1. - [Commits](https://github.com/qos-ch/logback/compare/v_1.4.14...v_1.5.1) --- updated-dependencies: - dependency-name: ch.qos.logback:logback-classic dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index e14b03ed4..c6686f9a7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,7 +21,7 @@ dependencyResolutionManagement { library("retrofit", "com.squareup.retrofit2:retrofit:2.9.0") library("retrofit-jackson", "com.squareup.retrofit2:converter-jackson:2.9.0") library("rx", "io.reactivex.rxjava3:rxjava:3.1.8") - library("logback", "ch.qos.logback:logback-classic:1.4.14") + library("logback", "ch.qos.logback:logback-classic:1.5.1") library("eventbus", "org.greenrobot:eventbus-java:3.3.1") library("netty-transport", "io.netty:netty-transport:4.1.106.Final") library("netty-codec", "io.netty:netty-codec:4.1.106.Final") From e6de56b755eed4693b0b9aa4124f2ba36d8ed012 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:43:27 +0000 Subject: [PATCH 75/87] [api]: Bump org.springframework.boot from 3.2.2 to 3.2.3 Bumps org.springframework.boot from 3.2.2 to 3.2.3. --- updated-dependencies: - dependency-name: org.springframework.boot dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- api/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/build.gradle.kts b/api/build.gradle.kts index ad7e2dfd2..b57cf5fb6 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -2,7 +2,7 @@ import org.springframework.boot.gradle.tasks.bundling.BootJar plugins { kotlin("jvm") - id("org.springframework.boot") version "3.2.2" + id("org.springframework.boot") version "3.2.3" id("io.spring.dependency-management") version "1.1.4" kotlin("plugin.spring") kotlin("kapt") From 7eceaa33dd8daa0b9aeb455e561a554d89ebacae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:39:05 +0000 Subject: [PATCH 76/87] [api]: Bump io.objectbox:objectbox-gradle-plugin from 3.7.1 to 3.8.0 Bumps [io.objectbox:objectbox-gradle-plugin](https://github.com/objectbox/objectbox-java) from 3.7.1 to 3.8.0. - [Release notes](https://github.com/objectbox/objectbox-java/releases) - [Commits](https://github.com/objectbox/objectbox-java/compare/V3.7.1...V3.8.0) --- updated-dependencies: - dependency-name: io.objectbox:objectbox-gradle-plugin dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 6fbb70f6e..1074c3d77 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ buildscript { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.0-Beta4") classpath("org.jetbrains.kotlin:kotlin-allopen:2.0.0-Beta4") classpath("com.adarshr:gradle-test-logger-plugin:4.0.0") - classpath("io.objectbox:objectbox-gradle-plugin:3.7.1") + classpath("io.objectbox:objectbox-gradle-plugin:3.8.0") } repositories { From a8c960dff08efa90781ce5d2bf3b9e56e5d454a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:39:00 +0000 Subject: [PATCH 77/87] [api]: Bump commons-codec:commons-codec from 1.16.0 to 1.16.1 Bumps [commons-codec:commons-codec](https://github.com/apache/commons-codec) from 1.16.0 to 1.16.1. - [Changelog](https://github.com/apache/commons-codec/blob/master/RELEASE-NOTES.txt) - [Commits](https://github.com/apache/commons-codec/compare/rel/commons-codec-1.16.0...rel/commons-codec-1.16.1) --- updated-dependencies: - dependency-name: commons-codec:commons-codec dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index c6686f9a7..474101533 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,7 +28,7 @@ dependencyResolutionManagement { library("xml", "com.fasterxml:aalto-xml:1.3.2") library("csv", "de.siegmar:fastcsv:3.0.0") library("apache-lang3", "org.apache.commons:commons-lang3:3.14.0") - library("apache-codec", "commons-codec:commons-codec:1.16.0") + library("apache-codec", "commons-codec:commons-codec:1.16.1") library("apache-collections", "org.apache.commons:commons-collections4:4.4") library("oshi", "com.github.oshi:oshi-core:6.4.13") library("timeshape", "net.iakovlev:timeshape:2022g.17") From 25227e61eb1f77c81c7da859caf75d9fc8ee7550 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:38:45 +0000 Subject: [PATCH 78/87] [api]: Bump the netty group with 2 updates Bumps the netty group with 2 updates: [io.netty:netty-transport](https://github.com/netty/netty) and [io.netty:netty-codec](https://github.com/netty/netty). Updates `io.netty:netty-transport` from 4.1.106.Final to 4.1.107.Final - [Commits](https://github.com/netty/netty/compare/netty-4.1.106.Final...netty-4.1.107.Final) Updates `io.netty:netty-codec` from 4.1.106.Final to 4.1.107.Final - [Commits](https://github.com/netty/netty/compare/netty-4.1.106.Final...netty-4.1.107.Final) --- updated-dependencies: - dependency-name: io.netty:netty-transport dependency-type: direct:production update-type: version-update:semver-patch dependency-group: netty - dependency-name: io.netty:netty-codec dependency-type: direct:production update-type: version-update:semver-patch dependency-group: netty ... Signed-off-by: dependabot[bot] --- settings.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 474101533..11ad21ad8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,8 +23,8 @@ dependencyResolutionManagement { library("rx", "io.reactivex.rxjava3:rxjava:3.1.8") library("logback", "ch.qos.logback:logback-classic:1.5.1") library("eventbus", "org.greenrobot:eventbus-java:3.3.1") - library("netty-transport", "io.netty:netty-transport:4.1.106.Final") - library("netty-codec", "io.netty:netty-codec:4.1.106.Final") + library("netty-transport", "io.netty:netty-transport:4.1.107.Final") + library("netty-codec", "io.netty:netty-codec:4.1.107.Final") library("xml", "com.fasterxml:aalto-xml:1.3.2") library("csv", "de.siegmar:fastcsv:3.0.0") library("apache-lang3", "org.apache.commons:commons-lang3:3.14.0") From 1c6c4d1b051fb8ac870554ee56c5f6ded1d02b39 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:43:43 +0000 Subject: [PATCH 79/87] [desktop]: Bump the types group in /desktop with 1 update Bumps the types group in /desktop with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node). Updates `@types/node` from 20.11.14 to 20.11.24 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-patch dependency-group: types ... Signed-off-by: dependabot[bot] --- desktop/package-lock.json | 8 ++++---- desktop/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 63bdaefba..11c8f9397 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -43,7 +43,7 @@ "@angular/cli": "17.1.2", "@angular/compiler-cli": "17.1.2", "@types/leaflet": "1.9.8", - "@types/node": "20.11.14", + "@types/node": "20.11.24", "@types/uuid": "9.0.8", "electron": "28.2.1", "electron-builder": "24.9.1", @@ -4340,9 +4340,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.11.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.14.tgz", - "integrity": "sha512-w3yWCcwULefjP9DmDDsgUskrMoOy5Z8MiwKHr1FvqGPtx7CvJzQvxD7eKpxNtklQxLruxSXWddyeRtyud0RcXQ==", + "version": "20.11.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", + "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", "dev": true, "dependencies": { "undici-types": "~5.26.4" diff --git a/desktop/package.json b/desktop/package.json index 34c8ee6bd..0d9b3c3cc 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -62,7 +62,7 @@ "@angular/cli": "17.1.2", "@angular/compiler-cli": "17.1.2", "@types/leaflet": "1.9.8", - "@types/node": "20.11.14", + "@types/node": "20.11.24", "@types/uuid": "9.0.8", "electron": "28.2.1", "electron-builder": "24.9.1", From b840a47a22a82d9e205e2017bba2d678037740d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:43:57 +0000 Subject: [PATCH 80/87] [desktop]: Bump chart.js from 4.4.1 to 4.4.2 in /desktop Bumps [chart.js](https://github.com/chartjs/Chart.js) from 4.4.1 to 4.4.2. - [Release notes](https://github.com/chartjs/Chart.js/releases) - [Commits](https://github.com/chartjs/Chart.js/compare/v4.4.1...v4.4.2) --- updated-dependencies: - dependency-name: chart.js dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- desktop/package-lock.json | 10 +++++----- desktop/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 11c8f9397..d5156d798 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -22,7 +22,7 @@ "@angular/router": "17.1.2", "@fontsource/roboto": "5.0.8", "@mdi/font": "7.4.47", - "chart.js": "4.4.1", + "chart.js": "4.4.2", "chartjs-plugin-zoom": "2.0.1", "hotkeys-js": "3.13.7", "interactjs": "1.10.26", @@ -6070,14 +6070,14 @@ "dev": true }, "node_modules/chart.js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.1.tgz", - "integrity": "sha512-C74QN1bxwV1v2PEujhmKjOZ7iUM4w6BWs23Md/6aOZZSlwMzeCIDGuZay++rBgChYru7/+QFeoQW0fQoP534Dg==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.2.tgz", + "integrity": "sha512-6GD7iKwFpP5kbSD4MeRRRlTnQvxfQREy36uEtm1hzHzcOqwWx0YEHuspuoNlslu+nciLIB7fjjsHkUv/FzFcOg==", "dependencies": { "@kurkle/color": "^0.3.0" }, "engines": { - "pnpm": ">=7" + "pnpm": ">=8" } }, "node_modules/chartjs-plugin-zoom": { diff --git a/desktop/package.json b/desktop/package.json index 0d9b3c3cc..0f0c26253 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -41,7 +41,7 @@ "@angular/router": "17.1.2", "@fontsource/roboto": "5.0.8", "@mdi/font": "7.4.47", - "chart.js": "4.4.1", + "chart.js": "4.4.2", "chartjs-plugin-zoom": "2.0.1", "hotkeys-js": "3.13.7", "interactjs": "1.10.26", From caf81516495427af051045dc24b6783185240416 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:45:12 +0000 Subject: [PATCH 81/87] [desktop]: Bump zone.js from 0.14.3 to 0.14.4 in /desktop Bumps [zone.js](https://github.com/angular/angular/tree/HEAD/packages/zone.js) from 0.14.3 to 0.14.4. - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/packages/zone.js/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/zone.js-0.14.4/packages/zone.js) --- updated-dependencies: - dependency-name: zone.js dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- desktop/package-lock.json | 8 ++++---- desktop/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index d5156d798..7d7ba7724 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -35,7 +35,7 @@ "rxjs": "7.8.1", "tslib": "2.6.2", "uuid": "9.0.1", - "zone.js": "0.14.3" + "zone.js": "0.14.4" }, "devDependencies": { "@angular-builders/custom-webpack": "17.0.0", @@ -16328,9 +16328,9 @@ } }, "node_modules/zone.js": { - "version": "0.14.3", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.3.tgz", - "integrity": "sha512-jYoNqF046Q+JfcZSItRSt+oXFcpXL88yq7XAZjb/NKTS7w2hHpKjRJ3VlFD1k75wMaRRXNUt5vrZVlygiMyHbA==", + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.4.tgz", + "integrity": "sha512-NtTUvIlNELez7Q1DzKVIFZBzNb646boQMgpATo9z3Ftuu/gWvzxCW7jdjcUDoRGxRikrhVHB/zLXh1hxeJawvw==", "dependencies": { "tslib": "^2.3.0" } diff --git a/desktop/package.json b/desktop/package.json index 0f0c26253..230b3c840 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -54,7 +54,7 @@ "rxjs": "7.8.1", "tslib": "2.6.2", "uuid": "9.0.1", - "zone.js": "0.14.3" + "zone.js": "0.14.4" }, "devDependencies": { "@angular-builders/custom-webpack": "17.0.0", From e9747ab2bc21466c94b7fcc34f69ebbcd05ec189 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:45:57 +0000 Subject: [PATCH 82/87] [desktop]: Bump electron-builder from 24.9.1 to 24.12.0 in /desktop Bumps [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder) from 24.9.1 to 24.12.0. - [Release notes](https://github.com/electron-userland/electron-builder/releases) - [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-builder/CHANGELOG.md) - [Commits](https://github.com/electron-userland/electron-builder/commits/v24.12.0/packages/electron-builder) --- updated-dependencies: - dependency-name: electron-builder dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- desktop/package-lock.json | 68 ++++++++++++++++++--------------------- desktop/package.json | 2 +- 2 files changed, 33 insertions(+), 37 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 7d7ba7724..87047ad0f 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -46,7 +46,7 @@ "@types/node": "20.11.24", "@types/uuid": "9.0.8", "electron": "28.2.1", - "electron-builder": "24.9.1", + "electron-builder": "24.12.0", "electron-debug": "3.2.0", "electron-reloader": "1.2.3", "node-polyfill-webpack-plugin": "3.0.0", @@ -4915,9 +4915,9 @@ "dev": true }, "node_modules/app-builder-lib": { - "version": "24.9.1", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.9.1.tgz", - "integrity": "sha512-Q1nYxZcio4r+W72cnIRVYofEAyjBd3mG47o+zms8HlD51zWtA/YxJb01Jei5F+jkWhge/PTQK+uldsPh6d0/4g==", + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.12.0.tgz", + "integrity": "sha512-t/xinVrMbsEhwljLDoFOtGkiZlaxY1aceZbHERGAS02EkUHJp9lgs/+L8okXLlYCaDSqYdB05Yb8Co+krvguXA==", "dev": true, "dependencies": { "@develar/schema-utils": "~2.6.5", @@ -4926,15 +4926,14 @@ "@electron/universal": "1.4.1", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", - "7zip-bin": "~5.2.0", "async-exit-hook": "^2.0.1", "bluebird-lst": "^1.0.9", - "builder-util": "24.8.1", + "builder-util": "24.9.4", "builder-util-runtime": "9.2.3", "chromium-pickle-js": "^0.2.0", "debug": "^4.3.4", "ejs": "^3.1.8", - "electron-publish": "24.8.1", + "electron-publish": "24.9.4", "form-data": "^4.0.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", @@ -5705,9 +5704,9 @@ "dev": true }, "node_modules/builder-util": { - "version": "24.8.1", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-24.8.1.tgz", - "integrity": "sha512-ibmQ4BnnqCnJTNrdmdNlnhF48kfqhNzSeqFMXHLIl+o9/yhn6QfOaVrloZ9YUu3m0k3rexvlT5wcki6LWpjTZw==", + "version": "24.9.4", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-24.9.4.tgz", + "integrity": "sha512-YNon3rYjPSm4XDDho9wD6jq7vLRJZUy9FR+yFZnHoWvvdVCnZakL4BctTlPABP41MvIH5yk2cTZ2YfkOhGistQ==", "dev": true, "dependencies": { "@types/debug": "^4.1.6", @@ -7163,13 +7162,13 @@ } }, "node_modules/dmg-builder": { - "version": "24.9.1", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.9.1.tgz", - "integrity": "sha512-huC+O6hvHd24Ubj3cy2GMiGLe2xGFKN3klqVMLAdcbB6SWMd1yPSdZvV8W1O01ICzCCRlZDHiv4VrNUgnPUfbQ==", + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.12.0.tgz", + "integrity": "sha512-nS22OyHUIYcK40UnILOtqC5Qffd1SN1Ljqy/6e+QR2H1wM3iNBrKJoEbDRfEmYYaALKNFRkKPqSbZKRsGUBdPw==", "dev": true, "dependencies": { - "app-builder-lib": "24.9.1", - "builder-util": "24.8.1", + "app-builder-lib": "24.12.0", + "builder-util": "24.9.4", "builder-util-runtime": "9.2.3", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", @@ -7422,16 +7421,16 @@ } }, "node_modules/electron-builder": { - "version": "24.9.1", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.9.1.tgz", - "integrity": "sha512-v7BuakDuY6sKMUYM8mfQGrwyjBpZ/ObaqnenU0H+igEL10nc6ht049rsCw2HghRBdEwJxGIBuzs3jbEhNaMDmg==", + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.12.0.tgz", + "integrity": "sha512-dH4O9zkxFxFbBVFobIR5FA71yJ1TZSCvjZ2maCskpg7CWjBF+SNRSQAThlDyUfRuB+jBTMwEMzwARywmap0CSw==", "dev": true, "dependencies": { - "app-builder-lib": "24.9.1", - "builder-util": "24.8.1", + "app-builder-lib": "24.12.0", + "builder-util": "24.9.4", "builder-util-runtime": "9.2.3", "chalk": "^4.1.2", - "dmg-builder": "24.9.1", + "dmg-builder": "24.12.0", "fs-extra": "^10.1.0", "is-ci": "^3.0.0", "lazy-val": "^1.0.5", @@ -7590,13 +7589,13 @@ } }, "node_modules/electron-publish": { - "version": "24.8.1", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.8.1.tgz", - "integrity": "sha512-IFNXkdxMVzUdweoLJNXSupXkqnvgbrn3J4vognuOY06LaS/m0xvfFYIf+o1CM8if6DuWYWoQFKPcWZt/FUjZPw==", + "version": "24.9.4", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.9.4.tgz", + "integrity": "sha512-FghbeVMfxHneHjsG2xUSC0NMZYWOOWhBxfZKPTbibcJ0CjPH0Ph8yb5CUO62nqywXfA5u1Otq6K8eOdOixxmNg==", "dev": true, "dependencies": { "@types/fs-extra": "^9.0.11", - "builder-util": "24.8.1", + "builder-util": "24.9.4", "builder-util-runtime": "9.2.3", "chalk": "^4.1.2", "fs-extra": "^10.1.0", @@ -10142,12 +10141,12 @@ "dev": true }, "node_modules/isbinaryfile": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.0.tgz", - "integrity": "sha512-UDdnyGvMajJUWCkib7Cei/dvyJrrvo4FIrsvSFWdPpXSUorzXrDJ0S+X5Q4ZlasfPjca4yqCNNsjbCeiy8FFeg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.2.tgz", + "integrity": "sha512-GvcjojwonMjWbTkfMpnVHVqXW/wKMYDfEpY94/8zy8HFMOqb/VL6oeONq9v87q4ttVlaTLnGXnJD4B5B1OTGIg==", "dev": true, "engines": { - "node": ">= 14.0.0" + "node": ">= 18.0.0" }, "funding": { "url": "https://github.com/sponsors/gjtorikian/" @@ -15136,15 +15135,12 @@ } }, "node_modules/tmp-promise/node_modules/tmp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", "dev": true, - "dependencies": { - "rimraf": "^3.0.0" - }, "engines": { - "node": ">=8.17.0" + "node": ">=14.14" } }, "node_modules/to-fast-properties": { diff --git a/desktop/package.json b/desktop/package.json index 230b3c840..c1003e81b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -65,7 +65,7 @@ "@types/node": "20.11.24", "@types/uuid": "9.0.8", "electron": "28.2.1", - "electron-builder": "24.9.1", + "electron-builder": "24.12.0", "electron-debug": "3.2.0", "electron-reloader": "1.2.3", "node-polyfill-webpack-plugin": "3.0.0", From d7f933bca7672b87094c32265abf49f162ef0418 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:44:14 +0000 Subject: [PATCH 83/87] [desktop]: Bump primeng from 17.4.0 to 17.9.0 in /desktop Bumps [primeng](https://github.com/primefaces/primeng) from 17.4.0 to 17.9.0. - [Release notes](https://github.com/primefaces/primeng/releases) - [Changelog](https://github.com/primefaces/primeng/blob/master/CHANGELOG.md) - [Commits](https://github.com/primefaces/primeng/compare/17.4.0...17.9.0) --- updated-dependencies: - dependency-name: primeng dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- desktop/package-lock.json | 8 ++++---- desktop/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 87047ad0f..1341c6078 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -31,7 +31,7 @@ "panzoom": "9.4.3", "primeflex": "3.3.1", "primeicons": "6.0.1", - "primeng": "17.4.0", + "primeng": "17.9.0", "rxjs": "7.8.1", "tslib": "2.6.2", "uuid": "9.0.1", @@ -12898,9 +12898,9 @@ "integrity": "sha512-KDeO94CbWI4pKsPnYpA1FPjo79EsY9I+M8ywoPBSf9XMXoe/0crjbUK7jcQEDHuc0ZMRIZsxH3TYLv4TUtHmAA==" }, "node_modules/primeng": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/primeng/-/primeng-17.4.0.tgz", - "integrity": "sha512-fqIFXORQjfTZFepg1MBz/vqheMjHy104ugxuTT1BCzEbVsjc2bjVyedEsS3u+N7bYag7TLMvv/FjzbTAq6hzOw==", + "version": "17.9.0", + "resolved": "https://registry.npmjs.org/primeng/-/primeng-17.9.0.tgz", + "integrity": "sha512-XGeyponzYKpBYj2vD/H+vKPgnl4v8RQSEZmZEbo6Nr8NU4PZiDSvHRhqkOChsmqegmGvS8cLal1R2YedKcYykg==", "dependencies": { "tslib": "^2.3.0" }, diff --git a/desktop/package.json b/desktop/package.json index c1003e81b..bb4755900 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -50,7 +50,7 @@ "panzoom": "9.4.3", "primeflex": "3.3.1", "primeicons": "6.0.1", - "primeng": "17.4.0", + "primeng": "17.9.0", "rxjs": "7.8.1", "tslib": "2.6.2", "uuid": "9.0.1", From 5199340edd62a4271ff53a90793876c5b21907cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:53:58 +0000 Subject: [PATCH 84/87] [desktop]: Bump electron from 28.2.1 to 29.1.0 in /desktop Bumps [electron](https://github.com/electron/electron) from 28.2.1 to 29.1.0. - [Release notes](https://github.com/electron/electron/releases) - [Changelog](https://github.com/electron/electron/blob/main/docs/breaking-changes.md) - [Commits](https://github.com/electron/electron/compare/v28.2.1...v29.1.0) --- updated-dependencies: - dependency-name: electron dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- desktop/package-lock.json | 19 +++++-------------- desktop/package.json | 2 +- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 1341c6078..c02c6e455 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -45,7 +45,7 @@ "@types/leaflet": "1.9.8", "@types/node": "20.11.24", "@types/uuid": "9.0.8", - "electron": "28.2.1", + "electron": "29.1.0", "electron-builder": "24.12.0", "electron-debug": "3.2.0", "electron-reloader": "1.2.3", @@ -7403,14 +7403,14 @@ } }, "node_modules/electron": { - "version": "28.2.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-28.2.1.tgz", - "integrity": "sha512-wlzXf+OvOiVlBf9dcSeMMf7Q+N6DG+wtgFbMK0sA/JpIJcdosRbLMQwLg/LTwNVKIbmayqFLDp4FmmFkEMhbYA==", + "version": "29.1.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-29.1.0.tgz", + "integrity": "sha512-giJVIm0sWVp+8V1GXrKqKTb+h7no0P3ooYqEd34AD9wMJzGnAeL+usj+R0155/0pdvvP1mgydnA7lcaFA2M9lw==", "dev": true, "hasInstallScript": true, "dependencies": { "@electron/get": "^2.0.0", - "@types/node": "^18.11.18", + "@types/node": "^20.9.0", "extract-zip": "^2.0.1" }, "bin": { @@ -7861,15 +7861,6 @@ "integrity": "sha512-wA2A2LQCqnEwQAvwADQq3KpMpNwgAUBnRmrFgRzHnPhbQUFArTR32Ab46f4p0MovDLcg4uqd4nCsN2hTltslpA==", "dev": true }, - "node_modules/electron/node_modules/@types/node": { - "version": "18.19.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.12.tgz", - "integrity": "sha512-uLcpWEAvatBEubmgCMzWforZbAu1dT9syweWnU3/DNwbeUBq2miP5nG8Y4JL9MDMKWt+7Yv1CSvA8xELdEl54w==", - "dev": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, "node_modules/elliptic": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", diff --git a/desktop/package.json b/desktop/package.json index bb4755900..c4d4fe498 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -64,7 +64,7 @@ "@types/leaflet": "1.9.8", "@types/node": "20.11.24", "@types/uuid": "9.0.8", - "electron": "28.2.1", + "electron": "29.1.0", "electron-builder": "24.12.0", "electron-debug": "3.2.0", "electron-reloader": "1.2.3", From 8c4c2b32f3101ef8cbe768e5340e100b46903e4b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:52:33 +0000 Subject: [PATCH 85/87] [desktop]: Bump the angular group in /desktop with 14 updates Bumps the angular group in /desktop with 14 updates: | Package | From | To | | --- | --- | --- | | [@angular/animations](https://github.com/angular/angular/tree/HEAD/packages/animations) | `17.1.2` | `17.2.3` | | [@angular/cdk](https://github.com/angular/components) | `17.1.2` | `17.2.1` | | [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `17.1.2` | `17.2.3` | | [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `17.1.2` | `17.2.3` | | [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `17.1.2` | `17.2.3` | | [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `17.1.2` | `17.2.3` | | [@angular/language-service](https://github.com/angular/angular/tree/HEAD/packages/language-service) | `17.1.2` | `17.2.3` | | [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `17.1.2` | `17.2.3` | | [@angular/platform-browser-dynamic](https://github.com/angular/angular/tree/HEAD/packages/platform-browser-dynamic) | `17.1.2` | `17.2.3` | | [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `17.1.2` | `17.2.3` | | [@angular-builders/custom-webpack](https://github.com/just-jeb/angular-builders/tree/HEAD/packages/custom-webpack) | `17.0.0` | `17.0.1` | | [@angular-devkit/build-angular](https://github.com/angular/angular-cli) | `17.1.2` | `17.2.2` | | [@angular/cli](https://github.com/angular/angular-cli) | `17.1.2` | `17.2.2` | | [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `17.1.2` | `17.2.3` | Updates `@angular/animations` from 17.1.2 to 17.2.3 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/17.2.3/packages/animations) Updates `@angular/cdk` from 17.1.2 to 17.2.1 - [Release notes](https://github.com/angular/components/releases) - [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/components/compare/17.1.2...17.2.1) Updates `@angular/common` from 17.1.2 to 17.2.3 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/17.2.3/packages/common) Updates `@angular/compiler` from 17.1.2 to 17.2.3 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/17.2.3/packages/compiler) Updates `@angular/core` from 17.1.2 to 17.2.3 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/17.2.3/packages/core) Updates `@angular/forms` from 17.1.2 to 17.2.3 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/17.2.3/packages/forms) Updates `@angular/language-service` from 17.1.2 to 17.2.3 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/17.2.3/packages/language-service) Updates `@angular/platform-browser` from 17.1.2 to 17.2.3 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/17.2.3/packages/platform-browser) Updates `@angular/platform-browser-dynamic` from 17.1.2 to 17.2.3 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/17.2.3/packages/platform-browser-dynamic) Updates `@angular/router` from 17.1.2 to 17.2.3 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/17.2.3/packages/router) Updates `@angular-builders/custom-webpack` from 17.0.0 to 17.0.1 - [Release notes](https://github.com/just-jeb/angular-builders/releases) - [Changelog](https://github.com/just-jeb/angular-builders/blob/master/packages/custom-webpack/CHANGELOG.md) - [Commits](https://github.com/just-jeb/angular-builders/commits/@angular-builders/custom-webpack@17.0.1/packages/custom-webpack) Updates `@angular-devkit/build-angular` from 17.1.2 to 17.2.2 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/17.1.2...17.2.2) Updates `@angular/cli` from 17.1.2 to 17.2.2 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/17.1.2...17.2.2) Updates `@angular/compiler-cli` from 17.1.2 to 17.2.3 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/17.2.3/packages/compiler-cli) --- updated-dependencies: - dependency-name: "@angular/animations" dependency-type: direct:production update-type: version-update:semver-minor dependency-group: angular - dependency-name: "@angular/cdk" dependency-type: direct:production update-type: version-update:semver-minor dependency-group: angular - dependency-name: "@angular/common" dependency-type: direct:production update-type: version-update:semver-minor dependency-group: angular - dependency-name: "@angular/compiler" dependency-type: direct:production update-type: version-update:semver-minor dependency-group: angular - dependency-name: "@angular/core" dependency-type: direct:production update-type: version-update:semver-minor dependency-group: angular - dependency-name: "@angular/forms" dependency-type: direct:production update-type: version-update:semver-minor dependency-group: angular - dependency-name: "@angular/language-service" dependency-type: direct:production update-type: version-update:semver-minor dependency-group: angular - dependency-name: "@angular/platform-browser" dependency-type: direct:production update-type: version-update:semver-minor dependency-group: angular - dependency-name: "@angular/platform-browser-dynamic" dependency-type: direct:production update-type: version-update:semver-minor dependency-group: angular - dependency-name: "@angular/router" dependency-type: direct:production update-type: version-update:semver-minor dependency-group: angular - dependency-name: "@angular-builders/custom-webpack" dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular-devkit/build-angular" dependency-type: direct:development update-type: version-update:semver-minor dependency-group: angular - dependency-name: "@angular/cli" dependency-type: direct:development update-type: version-update:semver-minor dependency-group: angular - dependency-name: "@angular/compiler-cli" dependency-type: direct:development update-type: version-update:semver-minor dependency-group: angular ... Signed-off-by: dependabot[bot] --- desktop/package-lock.json | 1707 +++++++++++++++++++++---------------- desktop/package.json | 28 +- 2 files changed, 984 insertions(+), 751 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index c02c6e455..1d9c44abd 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -10,16 +10,16 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@angular/animations": "17.1.2", - "@angular/cdk": "17.1.2", - "@angular/common": "17.1.2", - "@angular/compiler": "17.1.2", - "@angular/core": "17.1.2", - "@angular/forms": "17.1.2", - "@angular/language-service": "17.1.2", - "@angular/platform-browser": "17.1.2", - "@angular/platform-browser-dynamic": "17.1.2", - "@angular/router": "17.1.2", + "@angular/animations": "17.2.3", + "@angular/cdk": "17.2.1", + "@angular/common": "17.2.3", + "@angular/compiler": "17.2.3", + "@angular/core": "17.2.3", + "@angular/forms": "17.2.3", + "@angular/language-service": "17.2.3", + "@angular/platform-browser": "17.2.3", + "@angular/platform-browser-dynamic": "17.2.3", + "@angular/router": "17.2.3", "@fontsource/roboto": "5.0.8", "@mdi/font": "7.4.47", "chart.js": "4.4.2", @@ -38,10 +38,10 @@ "zone.js": "0.14.4" }, "devDependencies": { - "@angular-builders/custom-webpack": "17.0.0", - "@angular-devkit/build-angular": "17.1.2", - "@angular/cli": "17.1.2", - "@angular/compiler-cli": "17.1.2", + "@angular-builders/custom-webpack": "17.0.1", + "@angular-devkit/build-angular": "17.2.2", + "@angular/cli": "17.2.2", + "@angular/compiler-cli": "17.2.3", "@types/leaflet": "1.9.8", "@types/node": "20.11.24", "@types/uuid": "9.0.8", @@ -72,18 +72,31 @@ "node": ">=6.0.0" } }, + "node_modules/@angular-builders/common": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@angular-builders/common/-/common-1.0.1.tgz", + "integrity": "sha512-qPgTjz3ISdGIY+vOIiUzpZRXwchdL/HEhCRzM2QKdqz/c5AB06X9wKhvXezabtzpYSq4lN9fliPYCntqimefFw==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "^17.1.0", + "ts-node": "^10.0.0", + "tsconfig-paths": "^4.1.0" + }, + "engines": { + "node": "^14.20.0 || ^16.13.0 || >=18.10.0" + } + }, "node_modules/@angular-builders/custom-webpack": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@angular-builders/custom-webpack/-/custom-webpack-17.0.0.tgz", - "integrity": "sha512-gKZKRzCE4cbDYyQLu1G/2CkAFbMd0oF07jMxX+jOTADzDeOy9mPOeBaFO60oWgeknrhXf31rynho55LGrHStkg==", + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@angular-builders/custom-webpack/-/custom-webpack-17.0.1.tgz", + "integrity": "sha512-wRmCy8B+/SPv10Ufy2WqDhU68UGxF6fPPGu2ZeBRqzh10axvdfyD20a4v8xITfAaraOJb/MA4qsUs0x96QQCCQ==", "dev": true, "dependencies": { + "@angular-builders/common": "1.0.1", "@angular-devkit/architect": ">=0.1700.0 < 0.1800.0", "@angular-devkit/build-angular": "^17.0.0", "@angular-devkit/core": "^17.0.0", "lodash": "^4.17.15", - "ts-node": "^10.0.0", - "tsconfig-paths": "^4.1.0", "webpack-merge": "^5.7.3" }, "engines": { @@ -94,12 +107,12 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1701.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1701.2.tgz", - "integrity": "sha512-g3gn5Ht6r9bCeFeAYF+HboZB8IvgvqqdeOnaWNaXJLI0ymEkpbqRdqrHGuVKHJV7JOMNXC7GPJEctBC6SXxOxA==", + "version": "0.1702.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1702.2.tgz", + "integrity": "sha512-qBvif8/NquFUqVQgs4U+8wXh/rQZv+YlYwg6eDZly1bIaTd/k9spko/seTtNT1OpK/Be+GLo5IbiQ7i2SON3iQ==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.1.2", + "@angular-devkit/core": "17.2.2", "rxjs": "7.8.1" }, "engines": { @@ -109,71 +122,70 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.1.2.tgz", - "integrity": "sha512-QIDTP+TjiCKCYRZYb8to4ymvIV1Djcfd5c17VdgMGhRqIQAAK1V4f4A1njdhGYOrgsLajZQAnKvFfk2ZMeI37A==", + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.2.2.tgz", + "integrity": "sha512-K55xBiWBfxD4wmxLR2viOPbBryOk6YaZeNr72IMkp1yIrIy1BES6LDJi7R9fDW7+TprqZdM4B91Tkc+BCwYQzQ==", "dev": true, "dependencies": { "@ampproject/remapping": "2.2.1", - "@angular-devkit/architect": "0.1701.2", - "@angular-devkit/build-webpack": "0.1701.2", - "@angular-devkit/core": "17.1.2", - "@babel/core": "7.23.7", + "@angular-devkit/architect": "0.1702.2", + "@angular-devkit/build-webpack": "0.1702.2", + "@angular-devkit/core": "17.2.2", + "@babel/core": "7.23.9", "@babel/generator": "7.23.6", "@babel/helper-annotate-as-pure": "7.22.5", "@babel/helper-split-export-declaration": "7.22.6", - "@babel/plugin-transform-async-generator-functions": "7.23.7", + "@babel/plugin-transform-async-generator-functions": "7.23.9", "@babel/plugin-transform-async-to-generator": "7.23.3", - "@babel/plugin-transform-runtime": "7.23.7", - "@babel/preset-env": "7.23.7", - "@babel/runtime": "7.23.7", + "@babel/plugin-transform-runtime": "7.23.9", + "@babel/preset-env": "7.23.9", + "@babel/runtime": "7.23.9", "@discoveryjs/json-ext": "0.5.7", - "@ngtools/webpack": "17.1.2", - "@vitejs/plugin-basic-ssl": "1.0.2", + "@ngtools/webpack": "17.2.2", + "@vitejs/plugin-basic-ssl": "1.1.0", "ansi-colors": "4.1.3", - "autoprefixer": "10.4.16", + "autoprefixer": "10.4.17", "babel-loader": "9.1.3", "babel-plugin-istanbul": "6.1.1", "browserslist": "^4.21.5", "copy-webpack-plugin": "11.0.0", "critters": "0.0.20", - "css-loader": "6.8.1", - "esbuild-wasm": "0.19.11", + "css-loader": "6.10.0", + "esbuild-wasm": "0.20.0", "fast-glob": "3.3.2", "http-proxy-middleware": "2.0.6", "https-proxy-agent": "7.0.2", - "inquirer": "9.2.12", - "jsonc-parser": "3.2.0", + "inquirer": "9.2.14", + "jsonc-parser": "3.2.1", "karma-source-map-support": "1.4.0", "less": "4.2.0", "less-loader": "11.1.0", "license-webpack-plugin": "4.0.2", "loader-utils": "3.2.1", - "magic-string": "0.30.5", - "mini-css-extract-plugin": "2.7.6", + "magic-string": "0.30.7", + "mini-css-extract-plugin": "2.8.0", "mrmime": "2.0.0", "open": "8.4.2", "ora": "5.4.1", "parse5-html-rewriting-stream": "7.0.0", - "picomatch": "3.0.1", - "piscina": "4.2.1", - "postcss": "8.4.33", - "postcss-loader": "7.3.4", + "picomatch": "4.0.1", + "piscina": "4.3.1", + "postcss": "8.4.35", + "postcss-loader": "8.1.0", "resolve-url-loader": "5.0.0", "rxjs": "7.8.1", - "sass": "1.69.7", - "sass-loader": "13.3.3", - "semver": "7.5.4", + "sass": "1.70.0", + "sass-loader": "14.1.0", + "semver": "7.6.0", "source-map-loader": "5.0.0", "source-map-support": "0.5.21", - "terser": "5.26.0", - "text-table": "0.2.0", + "terser": "5.27.0", "tree-kill": "1.2.2", "tslib": "2.6.2", - "undici": "6.2.1", + "undici": "6.6.2", "vite": "5.0.12", "watchpack": "2.4.0", - "webpack": "5.89.0", + "webpack": "5.90.1", "webpack-dev-middleware": "6.1.1", "webpack-dev-server": "4.15.1", "webpack-merge": "5.10.0", @@ -185,7 +197,7 @@ "yarn": ">= 1.13.0" }, "optionalDependencies": { - "esbuild": "0.19.11" + "esbuild": "0.20.0" }, "peerDependencies": { "@angular/compiler-cli": "^17.0.0", @@ -238,115 +250,13 @@ } } }, - "node_modules/@angular-devkit/build-angular/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "node_modules/@angular-devkit/build-angular/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/@angular-devkit/build-angular/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/webpack": { - "version": "5.89.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", - "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", - "dev": true, - "dependencies": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^1.0.0", - "@webassemblyjs/ast": "^1.11.5", - "@webassemblyjs/wasm-edit": "^1.11.5", - "@webassemblyjs/wasm-parser": "^1.11.5", - "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", - "browserslist": "^4.14.5", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.15.0", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.7", - "watchpack": "^2.4.0", - "webpack-sources": "^3.2.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1701.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1701.2.tgz", - "integrity": "sha512-LqfSO5iTbiYByDadUET/8uIun8vSHMEdtoxiil/kdZ5T0NG0p7K8QqUMnWgg6suwO6kFfYJkMiS8Dq3Y/ONUNQ==", + "version": "0.1702.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1702.2.tgz", + "integrity": "sha512-+c7rHD2Se1VD9i9uPEYHqhq8hTnsUAn5LfeJCLS8g7FU8T42tDSC/k1qWxHp7d99kf7ecg2BvYcZDlYaBUnl3A==", "dev": true, "dependencies": { - "@angular-devkit/architect": "0.1701.2", + "@angular-devkit/architect": "0.1702.2", "rxjs": "7.8.1" }, "engines": { @@ -360,15 +270,15 @@ } }, "node_modules/@angular-devkit/core": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.1.2.tgz", - "integrity": "sha512-ku+/W/HMCBacSWFppenr9y6Lx8mDuTuQvn1IkTyBLiJOpWnzgVbx9kHDeaDchGa1PwLlJUBBrv27t3qgJOIDPw==", + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.2.2.tgz", + "integrity": "sha512-bKMi6bBkEeN4a3qTxCykhrAvE0ESHhKO38Qh1bN/8QSyvKVAEyVAVls5W9IN5GKRHvXgEn9aw+DSzRnPpy9nyw==", "dev": true, "dependencies": { "ajv": "8.12.0", "ajv-formats": "2.1.1", - "jsonc-parser": "3.2.0", - "picomatch": "3.0.1", + "jsonc-parser": "3.2.1", + "picomatch": "4.0.1", "rxjs": "7.8.1", "source-map": "0.7.4" }, @@ -387,14 +297,14 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.1.2.tgz", - "integrity": "sha512-8S9RuM8olFN/gwN+mjbuF1CwHX61f0i59EGXz9tXLnKRUTjsRR+8vVMTAmX0dvVAT5fJTG/T69X+HX7FeumdqA==", + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.2.2.tgz", + "integrity": "sha512-t6dBhHvto9BEIo+Kew0+YyIS3TV1SEd4MActUk+zF4NNQyJ8wRUHL+8glUKB6ZWPyCTYSinJ+QKn/3yytELTHg==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.1.2", - "jsonc-parser": "3.2.0", - "magic-string": "0.30.5", + "@angular-devkit/core": "17.2.2", + "jsonc-parser": "3.2.1", + "magic-string": "0.30.7", "ora": "5.4.1", "rxjs": "7.8.1" }, @@ -405,9 +315,9 @@ } }, "node_modules/@angular/animations": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-17.1.2.tgz", - "integrity": "sha512-ZsHa/zoWBOZdispjcNgXCoF9MAtc6Zyzc/QFUjtOFI9vigOI8tWP6GY1Wfeg4cyL+R3uDGYBgMrdr8l84VfuKg==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-17.2.3.tgz", + "integrity": "sha512-eQcN6hC/dXISEYC/TjRuQJgfdZieBROBlXrS+BxRbsy9T4/QeKxChC3yiNxTmdxl5mvjLKvQTXHR8X0AWc07/Q==", "dependencies": { "tslib": "^2.3.0" }, @@ -415,13 +325,13 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/core": "17.1.2" + "@angular/core": "17.2.3" } }, "node_modules/@angular/cdk": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-17.1.2.tgz", - "integrity": "sha512-eu9D60RQv213qi7oh6ae9Z+d6+AG/aqi0y70Ag9BjwqTiatDiYvSySxswxYYKdzPp0hx0ZUTGi16LqtT6pyj6Q==", + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-17.2.1.tgz", + "integrity": "sha512-9cWV9MyWnpImns/WQApgoQBKblXA9Zx2CpCkDNipRgx9RyvGrvCLjpEfwQI4HjpPAQDI1trsbeJKihzgz4tFgw==", "dependencies": { "tslib": "^2.3.0" }, @@ -435,27 +345,27 @@ } }, "node_modules/@angular/cli": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.1.2.tgz", - "integrity": "sha512-U1W6XZNrfeRkXW2fO3AU25rRttqZahVkhzcK3lAtJ8+lSrStCOF7x1gz6tmFZFte1fNHQrXqD0yIDkd8H2/cvw==", + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.2.2.tgz", + "integrity": "sha512-cGGOnOTjU1bHBAU+5LMR1vfjUSmIY204pUcRAHu6xq1Qp8jm0Wf1lYOG1KrzpDezKa8d0WZe6FIVlxsCZRRYSw==", "dev": true, "dependencies": { - "@angular-devkit/architect": "0.1701.2", - "@angular-devkit/core": "17.1.2", - "@angular-devkit/schematics": "17.1.2", - "@schematics/angular": "17.1.2", + "@angular-devkit/architect": "0.1702.2", + "@angular-devkit/core": "17.2.2", + "@angular-devkit/schematics": "17.2.2", + "@schematics/angular": "17.2.2", "@yarnpkg/lockfile": "1.1.0", "ansi-colors": "4.1.3", "ini": "4.1.1", - "inquirer": "9.2.12", - "jsonc-parser": "3.2.0", + "inquirer": "9.2.14", + "jsonc-parser": "3.2.1", "npm-package-arg": "11.0.1", "npm-pick-manifest": "9.0.0", "open": "8.4.2", "ora": "5.4.1", - "pacote": "17.0.5", + "pacote": "17.0.6", "resolve": "1.22.8", - "semver": "7.5.4", + "semver": "7.6.0", "symbol-observable": "4.0.0", "yargs": "17.7.2" }, @@ -469,9 +379,9 @@ } }, "node_modules/@angular/common": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-17.1.2.tgz", - "integrity": "sha512-y/wD+zuPaPgK3dB80Q63qBtuu5TuryKuUgjWrOmrguBWV9oiJRhKQrcp1gVw9vVrowmbDBKGtPMS622Q4oxOWQ==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-17.2.3.tgz", + "integrity": "sha512-XR3rWS4W7/+RknyJMUUo9E81mSeyUznpclqTZ+Hy7+i4Naeso0qcRaIyr6JJmB5UGvlnfT1MlH9Fj78Dc80NEw==", "dependencies": { "tslib": "^2.3.0" }, @@ -479,14 +389,14 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/core": "17.1.2", + "@angular/core": "17.2.3", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-17.1.2.tgz", - "integrity": "sha512-1vJuQRM5V01nC6qsLvBKrHVZXpzbK0YKubwVQUXCSfDNZBcDFak3SQcwU4C2t880rU3ZvFDB1UWfk7CKn5w9Kw==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-17.2.3.tgz", + "integrity": "sha512-U2okLZ+4ipD5zTv32pMp+RsrM3kkP0XneSsIMPRpYZZfKgfnGLIwkRx6FoVoBwByugng6lBG/PiIe8DhRU/HFg==", "dependencies": { "tslib": "^2.3.0" }, @@ -494,7 +404,7 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/core": "17.1.2" + "@angular/core": "17.2.3" }, "peerDependenciesMeta": { "@angular/core": { @@ -503,16 +413,16 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.1.2.tgz", - "integrity": "sha512-4P4ttCe4IF9yq7bxCDxbVW7purN7qV0nqofP5Tth1xCsgIJeGmOMMQJN5RJCZNrAPMkvMv39eV878sgcDjbpOA==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.2.3.tgz", + "integrity": "sha512-mATybangypneXwO270VQeIw3N0avzc2Lpvdb8nm9WZYj23AcTUzpUUKOn63HtJdwMT5J2GjkyZFSRXisiPmpkA==", "dev": true, "dependencies": { - "@babel/core": "7.23.2", + "@babel/core": "7.23.9", "@jridgewell/sourcemap-codec": "^1.4.14", "chokidar": "^3.0.0", "convert-source-map": "^1.5.1", - "reflect-metadata": "^0.1.2", + "reflect-metadata": "^0.2.0", "semver": "^7.0.0", "tslib": "^2.3.0", "yargs": "^17.2.1" @@ -526,59 +436,14 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/compiler": "17.1.2", + "@angular/compiler": "17.2.3", "typescript": ">=5.2 <5.4" } }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", - "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-module-transforms": "^7.23.0", - "@babel/helpers": "^7.23.2", - "@babel/parser": "^7.23.0", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.2", - "@babel/types": "^7.23.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@angular/core": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-17.1.2.tgz", - "integrity": "sha512-0M787BZVgYSVogHCUzo/dFrT56TgfQoEsOQngHMpyERJZv6dycXZlRdHc6TzvHUa+Uu/MNjn/RclBR8063bdWA==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-17.2.3.tgz", + "integrity": "sha512-DU+RdUB4E4I489R2P2hOrgkCDJNXlVaTzYixpgeDnuldCIYM0MatEzjor9DYNL3EDCayHF+M4HlVOcn6T/IVPQ==", "dependencies": { "tslib": "^2.3.0" }, @@ -591,9 +456,9 @@ } }, "node_modules/@angular/forms": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.1.2.tgz", - "integrity": "sha512-n1WsZAL2IVOB6ocROKR6CFOR14PIC9RGAB41SwTfPhJeBM1kjW48bXY0sw97TasxM4mWJKGCmFXu0jQwkoeSpQ==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.2.3.tgz", + "integrity": "sha512-v+/6pimht808F5XpmVTNV4/109s+A7m3nadQP97qvIDsrtwrPPZR7cST+DRioG2C41VwtjXM0HVbIon/3ydo6A==", "dependencies": { "tslib": "^2.3.0" }, @@ -601,24 +466,24 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/common": "17.1.2", - "@angular/core": "17.1.2", - "@angular/platform-browser": "17.1.2", + "@angular/common": "17.2.3", + "@angular/core": "17.2.3", + "@angular/platform-browser": "17.2.3", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/language-service": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-17.1.2.tgz", - "integrity": "sha512-EqmbDT696a1KC04l5I4dilf86IJnj0jPxw8OXI9dlSQhsWYp8Egkc5+C0Hd7wmuHt/BeqSuMSJfk7DhfzKbx1w==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-17.2.3.tgz", + "integrity": "sha512-H4LUs2Ftdlk1iqHqC7jRcbHmnNRy53OUlBYNkjRkTsthOI4WqsiSqAp5Frrni3erBqpZ2ik86cbMEyEXcfjRhw==", "engines": { "node": "^18.13.0 || >=20.9.0" } }, "node_modules/@angular/platform-browser": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-17.1.2.tgz", - "integrity": "sha512-unfpA5OLnqDmDb/oAQR2t2iROpOg02qwZayxyFg4MUZdDdnghPCfX77L2sr6oVVa7OJfKYFlmwmBXX1H3zjcXA==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-17.2.3.tgz", + "integrity": "sha512-bFi+H8avyCjwSBy+zpOKmqx852MRH8fkuZa4XgwKCPJRay8BfSCjHdtIo3eokUNPMu9JsyXM7HYKIfzLu5y6LA==", "dependencies": { "tslib": "^2.3.0" }, @@ -626,9 +491,9 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/animations": "17.1.2", - "@angular/common": "17.1.2", - "@angular/core": "17.1.2" + "@angular/animations": "17.2.3", + "@angular/common": "17.2.3", + "@angular/core": "17.2.3" }, "peerDependenciesMeta": { "@angular/animations": { @@ -637,9 +502,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-17.1.2.tgz", - "integrity": "sha512-xiWVDHbA+owDhKo5SAnzZtawA1ktGthlCl3YTI+vmkJpF6axkYOqR7YL+aEQX/y/5GSK+oR+03SgAnYcpOwKlQ==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-17.2.3.tgz", + "integrity": "sha512-K8CsHbmG2nvV1jrNN9PYxyA0zJNoIWp+qf2udvPhG8rJ+Pyw61qmptrarpQUUkr8ONOtjwtOsnKa9/w+15nExw==", "dependencies": { "tslib": "^2.3.0" }, @@ -647,16 +512,16 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/common": "17.1.2", - "@angular/compiler": "17.1.2", - "@angular/core": "17.1.2", - "@angular/platform-browser": "17.1.2" + "@angular/common": "17.2.3", + "@angular/compiler": "17.2.3", + "@angular/core": "17.2.3", + "@angular/platform-browser": "17.2.3" } }, "node_modules/@angular/router": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-17.1.2.tgz", - "integrity": "sha512-8OexxiiscRdfEiB6jOKlZFyAKZtvIQvh0ugW6U7nAXPV5XsA2UL80sXkc829eH0DnJn2Wj/HS6ZNGgG81PWDHg==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-17.2.3.tgz", + "integrity": "sha512-8UPjMzI98xZ6cDNm0MzHd9hFq6aOQJGmgxKDUPIG2h74glRwwbiewpo5hPo2EGIF8BLvQmmAm9ytr5zesHu0cg==", "dependencies": { "tslib": "^2.3.0" }, @@ -664,18 +529,12 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/common": "17.1.2", - "@angular/core": "17.1.2", - "@angular/platform-browser": "17.1.2", + "@angular/common": "17.2.3", + "@angular/core": "17.2.3", + "@angular/platform-browser": "17.2.3", "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@assemblyscript/loader": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@assemblyscript/loader/-/loader-0.10.1.tgz", - "integrity": "sha512-H71nDOOL8Y7kWRLqf6Sums+01Q5msqBW2KhDUTemh1tvY04eSkSXrK0uj/4mmY0Xr16/3zyZmsrxN7CKuRbNRg==", - "dev": true - }, "node_modules/@babel/code-frame": { "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", @@ -699,9 +558,9 @@ } }, "node_modules/@babel/core": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.7.tgz", - "integrity": "sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", + "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", @@ -709,11 +568,11 @@ "@babel/generator": "^7.23.6", "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.23.7", - "@babel/parser": "^7.23.6", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.7", - "@babel/types": "^7.23.6", + "@babel/helpers": "^7.23.9", + "@babel/parser": "^7.23.9", + "@babel/template": "^7.23.9", + "@babel/traverse": "^7.23.9", + "@babel/types": "^7.23.9", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -808,9 +667,9 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.23.10", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.23.10.tgz", - "integrity": "sha512-2XpP2XhkXzgxecPNEEK8Vz8Asj9aRxt08oKOqtiZoqV2UGZ5T+EkyP9sXQ9nwMxBIG34a7jmasVqoMop7VdPUw==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.0.tgz", + "integrity": "sha512-QAH+vfvts51BCsNZ2PhY6HAggnlS6omLLFTsIpeqZk/MmJ6cW7tgz5yRv0fMJThcr6FmbMrENh1RgrWPTYA76g==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", @@ -971,9 +830,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", - "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz", + "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==", "dev": true, "engines": { "node": ">=6.9.0" @@ -1441,9 +1300,9 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.7.tgz", - "integrity": "sha512-PdxEpL71bJp1byMG0va5gwQcXHxuEYC/BgI/e88mGTtohbZN28O5Yit0Plkkm/dBzCF/BxmbNcses1RH1T+urA==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.9.tgz", + "integrity": "sha512-8Q3veQEDGe14dTYuwagbRtwxQDnytyg1JFu4/HwEMETeofocrB0U0ejBJIXoeG/t2oXZ8kzCyI0ZZfbT80VFNQ==", "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.22.20", @@ -1896,14 +1755,14 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.23.4.tgz", - "integrity": "sha512-9x9K1YyeQVw0iOXJlIzwm8ltobIIv7j2iLyP2jIhEbqPRQ7ScNgwQufU2I0Gq11VjyG4gI4yMXt2VFags+1N3g==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.0.tgz", + "integrity": "sha512-y/yKMm7buHpFFXfxVFS4Vk1ToRJDilIa6fKRioB9Vjichv58TDGXTvqV0dN7plobAmTW5eSEGXDngE+Mm+uO+w==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.23.3", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/compat-data": "^7.23.5", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-plugin-utils": "^7.24.0", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-transform-parameters": "^7.23.3" }, @@ -2059,16 +1918,16 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.23.7.tgz", - "integrity": "sha512-fa0hnfmiXc9fq/weK34MUV0drz2pOL/vfKWvN7Qw127hiUPabFCUMgAbYWcchRzMJit4o5ARsK/s+5h0249pLw==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.23.9.tgz", + "integrity": "sha512-A7clW3a0aSjm3ONU9o2HAILSegJCYlEZmOhmBRReVtIpY/Z/p7yIZ+wR41Z+UipwdGuqwtID/V/dOdZXjwi9gQ==", "dev": true, "dependencies": { "@babel/helper-module-imports": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", - "babel-plugin-polyfill-corejs2": "^0.4.7", - "babel-plugin-polyfill-corejs3": "^0.8.7", - "babel-plugin-polyfill-regenerator": "^0.5.4", + "babel-plugin-polyfill-corejs2": "^0.4.8", + "babel-plugin-polyfill-corejs3": "^0.9.0", + "babel-plugin-polyfill-regenerator": "^0.5.5", "semver": "^6.3.1" }, "engines": { @@ -2227,9 +2086,9 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.7.tgz", - "integrity": "sha512-SY27X/GtTz/L4UryMNJ6p4fH4nsgWbz84y9FE0bQeWJP6O5BhgVCt53CotQKHCOeXJel8VyhlhujhlltKms/CA==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.9.tgz", + "integrity": "sha512-3kBGTNBBk9DQiPoXYS0g0BYlwTQYUTifqgKTjxUwEUkduRT2QOa0FPGBJ+NROQhGyYO5BuTJwGvBnqKDykac6A==", "dev": true, "dependencies": { "@babel/compat-data": "^7.23.5", @@ -2259,13 +2118,13 @@ "@babel/plugin-syntax-top-level-await": "^7.14.5", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.23.3", - "@babel/plugin-transform-async-generator-functions": "^7.23.7", + "@babel/plugin-transform-async-generator-functions": "^7.23.9", "@babel/plugin-transform-async-to-generator": "^7.23.3", "@babel/plugin-transform-block-scoped-functions": "^7.23.3", "@babel/plugin-transform-block-scoping": "^7.23.4", "@babel/plugin-transform-class-properties": "^7.23.3", "@babel/plugin-transform-class-static-block": "^7.23.4", - "@babel/plugin-transform-classes": "^7.23.5", + "@babel/plugin-transform-classes": "^7.23.8", "@babel/plugin-transform-computed-properties": "^7.23.3", "@babel/plugin-transform-destructuring": "^7.23.3", "@babel/plugin-transform-dotall-regex": "^7.23.3", @@ -2281,7 +2140,7 @@ "@babel/plugin-transform-member-expression-literals": "^7.23.3", "@babel/plugin-transform-modules-amd": "^7.23.3", "@babel/plugin-transform-modules-commonjs": "^7.23.3", - "@babel/plugin-transform-modules-systemjs": "^7.23.3", + "@babel/plugin-transform-modules-systemjs": "^7.23.9", "@babel/plugin-transform-modules-umd": "^7.23.3", "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", "@babel/plugin-transform-new-target": "^7.23.3", @@ -2307,9 +2166,9 @@ "@babel/plugin-transform-unicode-regex": "^7.23.3", "@babel/plugin-transform-unicode-sets-regex": "^7.23.3", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.7", - "babel-plugin-polyfill-corejs3": "^0.8.7", - "babel-plugin-polyfill-regenerator": "^0.5.4", + "babel-plugin-polyfill-corejs2": "^0.4.8", + "babel-plugin-polyfill-corejs3": "^0.9.0", + "babel-plugin-polyfill-regenerator": "^0.5.5", "core-js-compat": "^3.31.0", "semver": "^6.3.1" }, @@ -2350,9 +2209,9 @@ "dev": true }, "node_modules/@babel/runtime": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.7.tgz", - "integrity": "sha512-w06OXVOFso7LcbzMiDGt+3X7Rh7Ho8MmgPoWU3rarH+8upf+wSU/grlGbWzQyr3DkdN6ZeuMFjpdwW0Q+HxobA==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", + "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" @@ -2753,9 +2612,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", - "integrity": "sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.0.tgz", + "integrity": "sha512-fGFDEctNh0CcSwsiRPxiaqX0P5rq+AqE0SRhYGZ4PX46Lg1FNR6oCxJghf8YgY0WQEgQuh3lErUFE4KxLeRmmw==", "cpu": [ "ppc64" ], @@ -2769,9 +2628,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.11.tgz", - "integrity": "sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.0.tgz", + "integrity": "sha512-3bMAfInvByLHfJwYPJRlpTeaQA75n8C/QKpEaiS4HrFWFiJlNI0vzq/zCjBrhAYcPyVPG7Eo9dMrcQXuqmNk5g==", "cpu": [ "arm" ], @@ -2785,9 +2644,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz", - "integrity": "sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.0.tgz", + "integrity": "sha512-aVpnM4lURNkp0D3qPoAzSG92VXStYmoVPOgXveAUoQBWRSuQzt51yvSju29J6AHPmwY1BjH49uR29oyfH1ra8Q==", "cpu": [ "arm64" ], @@ -2801,9 +2660,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.11.tgz", - "integrity": "sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.0.tgz", + "integrity": "sha512-uK7wAnlRvjkCPzh8jJ+QejFyrP8ObKuR5cBIsQZ+qbMunwR8sbd8krmMbxTLSrDhiPZaJYKQAU5Y3iMDcZPhyQ==", "cpu": [ "x64" ], @@ -2817,9 +2676,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz", - "integrity": "sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.0.tgz", + "integrity": "sha512-AjEcivGAlPs3UAcJedMa9qYg9eSfU6FnGHJjT8s346HSKkrcWlYezGE8VaO2xKfvvlZkgAhyvl06OJOxiMgOYQ==", "cpu": [ "arm64" ], @@ -2833,9 +2692,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz", - "integrity": "sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.0.tgz", + "integrity": "sha512-bsgTPoyYDnPv8ER0HqnJggXK6RyFy4PH4rtsId0V7Efa90u2+EifxytE9pZnsDgExgkARy24WUQGv9irVbTvIw==", "cpu": [ "x64" ], @@ -2849,9 +2708,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz", - "integrity": "sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.0.tgz", + "integrity": "sha512-kQ7jYdlKS335mpGbMW5tEe3IrQFIok9r84EM3PXB8qBFJPSc6dpWfrtsC/y1pyrz82xfUIn5ZrnSHQQsd6jebQ==", "cpu": [ "arm64" ], @@ -2865,9 +2724,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz", - "integrity": "sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.0.tgz", + "integrity": "sha512-uG8B0WSepMRsBNVXAQcHf9+Ko/Tr+XqmK7Ptel9HVmnykupXdS4J7ovSQUIi0tQGIndhbqWLaIL/qO/cWhXKyQ==", "cpu": [ "x64" ], @@ -2881,9 +2740,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz", - "integrity": "sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.0.tgz", + "integrity": "sha512-2ezuhdiZw8vuHf1HKSf4TIk80naTbP9At7sOqZmdVwvvMyuoDiZB49YZKLsLOfKIr77+I40dWpHVeY5JHpIEIg==", "cpu": [ "arm" ], @@ -2897,9 +2756,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz", - "integrity": "sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.0.tgz", + "integrity": "sha512-uTtyYAP5veqi2z9b6Gr0NUoNv9F/rOzI8tOD5jKcCvRUn7T60Bb+42NDBCWNhMjkQzI0qqwXkQGo1SY41G52nw==", "cpu": [ "arm64" ], @@ -2913,9 +2772,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz", - "integrity": "sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.0.tgz", + "integrity": "sha512-c88wwtfs8tTffPaoJ+SQn3y+lKtgTzyjkD8NgsyCtCmtoIC8RDL7PrJU05an/e9VuAke6eJqGkoMhJK1RY6z4w==", "cpu": [ "ia32" ], @@ -2929,9 +2788,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz", - "integrity": "sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.0.tgz", + "integrity": "sha512-lR2rr/128/6svngnVta6JN4gxSXle/yZEZL3o4XZ6esOqhyR4wsKyfu6qXAL04S4S5CgGfG+GYZnjFd4YiG3Aw==", "cpu": [ "loong64" ], @@ -2945,9 +2804,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz", - "integrity": "sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.0.tgz", + "integrity": "sha512-9Sycc+1uUsDnJCelDf6ZNqgZQoK1mJvFtqf2MUz4ujTxGhvCWw+4chYfDLPepMEvVL9PDwn6HrXad5yOrNzIsQ==", "cpu": [ "mips64el" ], @@ -2961,9 +2820,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz", - "integrity": "sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.0.tgz", + "integrity": "sha512-CoWSaaAXOZd+CjbUTdXIJE/t7Oz+4g90A3VBCHLbfuc5yUQU/nFDLOzQsN0cdxgXd97lYW/psIIBdjzQIwTBGw==", "cpu": [ "ppc64" ], @@ -2977,9 +2836,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz", - "integrity": "sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.0.tgz", + "integrity": "sha512-mlb1hg/eYRJUpv8h/x+4ShgoNLL8wgZ64SUr26KwglTYnwAWjkhR2GpoKftDbPOCnodA9t4Y/b68H4J9XmmPzA==", "cpu": [ "riscv64" ], @@ -2993,9 +2852,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz", - "integrity": "sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.0.tgz", + "integrity": "sha512-fgf9ubb53xSnOBqyvWEY6ukBNRl1mVX1srPNu06B6mNsNK20JfH6xV6jECzrQ69/VMiTLvHMicQR/PgTOgqJUQ==", "cpu": [ "s390x" ], @@ -3009,9 +2868,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz", - "integrity": "sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.0.tgz", + "integrity": "sha512-H9Eu6MGse++204XZcYsse1yFHmRXEWgadk2N58O/xd50P9EvFMLJTQLg+lB4E1cF2xhLZU5luSWtGTb0l9UeSg==", "cpu": [ "x64" ], @@ -3025,9 +2884,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz", - "integrity": "sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.0.tgz", + "integrity": "sha512-lCT675rTN1v8Fo+RGrE5KjSnfY0x9Og4RN7t7lVrN3vMSjy34/+3na0q7RIfWDAj0e0rCh0OL+P88lu3Rt21MQ==", "cpu": [ "x64" ], @@ -3041,9 +2900,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz", - "integrity": "sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.0.tgz", + "integrity": "sha512-HKoUGXz/TOVXKQ+67NhxyHv+aDSZf44QpWLa3I1lLvAwGq8x1k0T+e2HHSRvxWhfJrFxaaqre1+YyzQ99KixoA==", "cpu": [ "x64" ], @@ -3057,9 +2916,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz", - "integrity": "sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.0.tgz", + "integrity": "sha512-GDwAqgHQm1mVoPppGsoq4WJwT3vhnz/2N62CzhvApFD1eJyTroob30FPpOZabN+FgCjhG+AgcZyOPIkR8dfD7g==", "cpu": [ "x64" ], @@ -3073,9 +2932,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz", - "integrity": "sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.0.tgz", + "integrity": "sha512-0vYsP8aC4TvMlOQYozoksiaxjlvUcQrac+muDqj1Fxy6jh9l9CZJzj7zmh8JGfiV49cYLTorFLxg7593pGldwQ==", "cpu": [ "arm64" ], @@ -3089,9 +2948,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz", - "integrity": "sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.0.tgz", + "integrity": "sha512-p98u4rIgfh4gdpV00IqknBD5pC84LCub+4a3MO+zjqvU5MVXOc3hqR2UgT2jI2nh3h8s9EQxmOsVI3tyzv1iFg==", "cpu": [ "ia32" ], @@ -3105,9 +2964,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz", - "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.0.tgz", + "integrity": "sha512-NgJnesu1RtWihtTtXGFMU5YSE6JyyHPMxCwBZK7a6/8d31GuSo9l0Ss7w1Jw5QnKUawG6UEehs883kcXf5fYwg==", "cpu": [ "x64" ], @@ -3121,9 +2980,9 @@ } }, "node_modules/@fastify/busboy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz", - "integrity": "sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", "dev": true, "engines": { "node": ">=14" @@ -3435,9 +3294,9 @@ "integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==" }, "node_modules/@ngtools/webpack": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.1.2.tgz", - "integrity": "sha512-MdNVSIp0x8AK26L+CxMTXH4weq2sNIp4C09RSdk7y6UkfBxMA3O0jTto9tW3ehkBaaGZ4dSiWkXA8L/ydMiQmA==", + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.2.2.tgz", + "integrity": "sha512-HgvClGO6WVq4VA5d0ZvlDG5hrj8lQzRH99Gt87URm7G8E5XkatysdOsMqUQsJz+OwFWhP4PvTRWVblpBDiDl/A==", "dev": true, "engines": { "node": "^18.13.0 || >=20.9.0", @@ -3486,9 +3345,9 @@ } }, "node_modules/@npmcli/agent": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.0.tgz", - "integrity": "sha512-2yThA1Es98orMkpSLVqlDZAMPK3jHJhifP2gnNUdk1754uZ8yI5c+ulCoVG+WlntQA6MzhrURMXjSd9Z7dJ2/Q==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.1.tgz", + "integrity": "sha512-H4FrOVtNyWC8MUwL3UfjOsAihHvT1Pe8POj3JvjXhSTJipsZMtgUALCT4mGyYZNxymkUfOw3PUj6dE4QPp6osQ==", "dev": true, "dependencies": { "agent-base": "^7.1.0", @@ -3514,9 +3373,9 @@ } }, "node_modules/@npmcli/agent/node_modules/http-proxy-agent": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz", - "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "dependencies": { "agent-base": "^7.1.0", @@ -3787,9 +3646,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz", - "integrity": "sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.12.0.tgz", + "integrity": "sha512-+ac02NL/2TCKRrJu2wffk1kZ+RyqxVUlbjSagNgPm94frxtr+XDL12E5Ll1enWskLrtrZ2r8L3wED1orIibV/w==", "cpu": [ "arm" ], @@ -3800,9 +3659,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.6.tgz", - "integrity": "sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.12.0.tgz", + "integrity": "sha512-OBqcX2BMe6nvjQ0Nyp7cC90cnumt8PXmO7Dp3gfAju/6YwG0Tj74z1vKrfRz7qAv23nBcYM8BCbhrsWqO7PzQQ==", "cpu": [ "arm64" ], @@ -3813,9 +3672,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.6.tgz", - "integrity": "sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz", + "integrity": "sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ==", "cpu": [ "arm64" ], @@ -3826,9 +3685,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.6.tgz", - "integrity": "sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.12.0.tgz", + "integrity": "sha512-cc71KUZoVbUJmGP2cOuiZ9HSOP14AzBAThn3OU+9LcA1+IUqswJyR1cAJj3Mg55HbjZP6OLAIscbQsQLrpgTOg==", "cpu": [ "x64" ], @@ -3839,9 +3698,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.6.tgz", - "integrity": "sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.12.0.tgz", + "integrity": "sha512-a6w/Y3hyyO6GlpKL2xJ4IOh/7d+APaqLYdMf86xnczU3nurFTaVN9s9jOXQg97BE4nYm/7Ga51rjec5nfRdrvA==", "cpu": [ "arm" ], @@ -3852,9 +3711,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.6.tgz", - "integrity": "sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.12.0.tgz", + "integrity": "sha512-0fZBq27b+D7Ar5CQMofVN8sggOVhEtzFUwOwPppQt0k+VR+7UHMZZY4y+64WJ06XOhBTKXtQB/Sv0NwQMXyNAA==", "cpu": [ "arm64" ], @@ -3865,9 +3724,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.6.tgz", - "integrity": "sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.12.0.tgz", + "integrity": "sha512-eTvzUS3hhhlgeAv6bfigekzWZjaEX9xP9HhxB0Dvrdbkk5w/b+1Sxct2ZuDxNJKzsRStSq1EaEkVSEe7A7ipgQ==", "cpu": [ "arm64" ], @@ -3878,9 +3737,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.6.tgz", - "integrity": "sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.12.0.tgz", + "integrity": "sha512-ix+qAB9qmrCRiaO71VFfY8rkiAZJL8zQRXveS27HS+pKdjwUfEhqo2+YF2oI+H/22Xsiski+qqwIBxVewLK7sw==", "cpu": [ "riscv64" ], @@ -3891,9 +3750,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.6.tgz", - "integrity": "sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz", + "integrity": "sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA==", "cpu": [ "x64" ], @@ -3904,9 +3763,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.6.tgz", - "integrity": "sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.0.tgz", + "integrity": "sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw==", "cpu": [ "x64" ], @@ -3917,9 +3776,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.6.tgz", - "integrity": "sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.12.0.tgz", + "integrity": "sha512-JPDxovheWNp6d7AHCgsUlkuCKvtu3RB55iNEkaQcf0ttsDU/JZF+iQnYcQJSk/7PtT4mjjVG8N1kpwnI9SLYaw==", "cpu": [ "arm64" ], @@ -3930,9 +3789,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.6.tgz", - "integrity": "sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.12.0.tgz", + "integrity": "sha512-fjtuvMWRGJn1oZacG8IPnzIV6GF2/XG+h71FKn76OYFqySXInJtseAqdprVTDTyqPxQOG9Exak5/E9Z3+EJ8ZA==", "cpu": [ "ia32" ], @@ -3943,9 +3802,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.6.tgz", - "integrity": "sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.0.tgz", + "integrity": "sha512-ZYmr5mS2wd4Dew/JjT0Fqi2NPB/ZhZ2VvPp7SmvPZb4Y1CG/LRcS6tcRo2cYU7zLK5A7cdbhWnnWmUjoI4qapg==", "cpu": [ "x64" ], @@ -3956,14 +3815,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.1.2.tgz", - "integrity": "sha512-1GlH0POaN7hVDF1sAm90E5SvAqnKK+PbD1oKSpug9l+1AUQ3vOamyGhEAaO+IxUqvNdgqZexxd5o9MyySTT2Zw==", + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.2.2.tgz", + "integrity": "sha512-Q3VAQ/S4gj8D1JPWgWG4enDdDZUu8mUXWVRG1rOi4sHgOF5zgPieQFp3LXqMUgOncmzbXrctkbO6NKc4N2FAag==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.1.2", - "@angular-devkit/schematics": "17.1.2", - "jsonc-parser": "3.2.0" + "@angular-devkit/core": "17.2.2", + "@angular-devkit/schematics": "17.2.2", + "jsonc-parser": "3.2.1" }, "engines": { "node": "^18.13.0 || >=20.9.0", @@ -3993,44 +3852,44 @@ "dev": true }, "node_modules/@sigstore/bundle": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.1.tgz", - "integrity": "sha512-v3/iS+1nufZdKQ5iAlQKcCsoh0jffQyABvYIxKsZQFWc4ubuGjwZklFHpDgV6O6T7vvV78SW5NHI91HFKEcxKg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.2.0.tgz", + "integrity": "sha512-5VI58qgNs76RDrwXNhpmyN/jKpq9evV/7f1XrcqcAfvxDl5SeVY/I5Rmfe96ULAV7/FK5dge9RBKGBJPhL1WsQ==", "dev": true, "dependencies": { - "@sigstore/protobuf-specs": "^0.2.1" + "@sigstore/protobuf-specs": "^0.3.0" }, "engines": { "node": "^16.14.0 || >=18.0.0" } }, "node_modules/@sigstore/core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-0.2.0.tgz", - "integrity": "sha512-THobAPPZR9pDH2CAvDLpkrYedt7BlZnsyxDe+Isq4ZmGfPy5juOFZq487vCU2EgKD7aHSiTfE/i7sN7aEdzQnA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-1.0.0.tgz", + "integrity": "sha512-dW2qjbWLRKGu6MIDUTBuJwXCnR8zivcSpf5inUzk7y84zqy/dji0/uahppoIgMoKeR+6pUZucrwHfkQQtiG9Rw==", "dev": true, "engines": { "node": "^16.14.0 || >=18.0.0" } }, "node_modules/@sigstore/protobuf-specs": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.2.1.tgz", - "integrity": "sha512-XTWVxnWJu+c1oCshMLwnKvz8ZQJJDVOlciMfgpJBQbThVjKTCG8dwyhgLngBD2KN0ap9F/gOV8rFDEx8uh7R2A==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.0.tgz", + "integrity": "sha512-zxiQ66JFOjVvP9hbhGj/F/qNdsZfkGb/dVXSanNRNuAzMlr4MC95voPUBX8//ZNnmv3uSYzdfR/JSkrgvZTGxA==", "dev": true, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/@sigstore/sign": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.2.1.tgz", - "integrity": "sha512-U5sKQEj+faE1MsnLou1f4DQQHeFZay+V9s9768lw48J4pKykPj34rWyI1lsMOGJ3Mae47Ye6q3HAJvgXO21rkQ==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.2.3.tgz", + "integrity": "sha512-LqlA+ffyN02yC7RKszCdMTS6bldZnIodiox+IkT8B2f8oRYXCB3LQ9roXeiEL21m64CVH1wyveYAORfD65WoSw==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.1.1", - "@sigstore/core": "^0.2.0", - "@sigstore/protobuf-specs": "^0.2.1", + "@sigstore/bundle": "^2.2.0", + "@sigstore/core": "^1.0.0", + "@sigstore/protobuf-specs": "^0.3.0", "make-fetch-happen": "^13.0.0" }, "engines": { @@ -4038,12 +3897,12 @@ } }, "node_modules/@sigstore/tuf": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.0.tgz", - "integrity": "sha512-S98jo9cpJwO1mtQ+2zY7bOdcYyfVYCUaofCG6wWRzk3pxKHVAkSfshkfecto2+LKsx7Ovtqbgb2LS8zTRhxJ9Q==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.1.tgz", + "integrity": "sha512-9Iv40z652td/QbV0o5n/x25H9w6IYRt2pIGbTX55yFDYlApDQn/6YZomjz6+KBx69rXHLzHcbtTS586mDdFD+Q==", "dev": true, "dependencies": { - "@sigstore/protobuf-specs": "^0.2.1", + "@sigstore/protobuf-specs": "^0.3.0", "tuf-js": "^2.2.0" }, "engines": { @@ -4051,14 +3910,14 @@ } }, "node_modules/@sigstore/verify": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-0.1.0.tgz", - "integrity": "sha512-2UzMNYAa/uaz11NhvgRnIQf4gpLTJ59bhb8ESXaoSS5sxedfS+eLak8bsdMc+qpNQfITUTFoSKFx5h8umlRRiA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-1.1.0.tgz", + "integrity": "sha512-1fTqnqyTBWvV7cftUUFtDcHPdSox0N3Ub7C0lRyReYx4zZUlNTZjCV+HPy4Lre+r45dV7Qx5JLKvqqsgxuyYfg==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.1.1", - "@sigstore/core": "^0.2.0", - "@sigstore/protobuf-specs": "^0.2.1" + "@sigstore/bundle": "^2.2.0", + "@sigstore/core": "^1.0.0", + "@sigstore/protobuf-specs": "^0.3.0" }, "engines": { "node": "^16.14.0 || >=18.0.0" @@ -4256,9 +4115,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "4.17.42", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.42.tgz", - "integrity": "sha512-ckM3jm2bf/MfB3+spLPWYPUH573plBFwpOhqQ2WottxYV85j1HQFlxmnTq57X1yHY9awZPig06hL/cLMgNWHIQ==", + "version": "4.17.43", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz", + "integrity": "sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==", "dev": true, "dependencies": { "@types/node": "*", @@ -4369,9 +4228,9 @@ } }, "node_modules/@types/qs": { - "version": "6.9.11", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz", - "integrity": "sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==", + "version": "6.9.12", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.12.tgz", + "integrity": "sha512-bZcOkJ6uWrL0Qb2NAWKa7TBU+mJHPzhx9jjLL1KHF+XpzEcR7EXHvjbHlGtR/IsP1vyPrehuS6XqkmaePy//mg==", "dev": true }, "node_modules/@types/range-parser": { @@ -4467,9 +4326,9 @@ } }, "node_modules/@vitejs/plugin-basic-ssl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.0.2.tgz", - "integrity": "sha512-DKHKVtpI+eA5fvObVgQ3QtTGU70CcCnedalzqmGSR050AzKZMdUzgC8KmlOneHWH8dF2hJ3wkC9+8FDVAaDRCw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.1.0.tgz", + "integrity": "sha512-wO4Dk/rm8u7RNhOf95ZzcEmC9rYOncYgvq4z3duaJrCgjN8BxAnDVyndanfcJZ0O6XZzHz6Q0hTimxTg8Y9g/A==", "dev": true, "engines": { "node": ">=14.6.0" @@ -5142,9 +5001,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.16", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", - "integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==", + "version": "10.4.17", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", + "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", "dev": true, "funding": [ { @@ -5161,9 +5020,9 @@ } ], "dependencies": { - "browserslist": "^4.21.10", - "caniuse-lite": "^1.0.30001538", - "fraction.js": "^4.3.6", + "browserslist": "^4.22.2", + "caniuse-lite": "^1.0.30001578", + "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.0.0", "postcss-value-parser": "^4.2.0" @@ -5258,29 +5117,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.8.7", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.7.tgz", - "integrity": "sha512-KyDvZYxAzkC0Aj2dAPyDzi2Ym15e5JKZSK+maI7NAwSqofvuFglbSsxE7wUOvTg9oFVnHMzVzBKcqEb4PJgtOA==", - "dev": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.4", - "core-js-compat": "^3.33.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs3/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.4.tgz", - "integrity": "sha512-QcJMILQCu2jm5TFPGA3lCpJJTeEP+mqeXooG/NZbg/h5FTFi6V0+99ahlRsW8/kRLyb24LZVCCiclDedhLKcBA==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.9.0.tgz", + "integrity": "sha512-7nZPG1uzK2Ymhy/NbaOWTg3uibM2BmGASS4vHS4szRZAIR8R6GwA/xAujpdrXU5iyklrimWnLWU+BLF9suPTqg==", "dev": true, "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" + "@babel/helper-define-polyfill-provider": "^0.5.0", + "core-js-compat": "^3.34.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -5400,13 +5243,13 @@ "dev": true }, "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dev": true, "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -5414,7 +5257,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -6610,12 +6453,12 @@ } }, "node_modules/core-js-compat": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.1.tgz", - "integrity": "sha512-sftHa5qUJY3rs9Zht1WEnmkvXputCyDBczPnr7QDgL8n3qrF3CMXY4VPSYtOLLiOUJcah2WNXREd48iOl6mQIw==", + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.0.tgz", + "integrity": "sha512-iV9Pd/PsgjNWBXeq8XRtWVSgz2tKAfhfvBs7qxYty+RlRd+OCksaWmOnc4JKrTc1cToXL1N0s3l/vwlxPtdElw==", "dev": true, "dependencies": { - "browserslist": "^4.22.2" + "browserslist": "^4.22.3" }, "funding": { "type": "opencollective", @@ -6629,15 +6472,15 @@ "dev": true }, "node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "dependencies": { + "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" + "parse-json": "^5.2.0" }, "engines": { "node": ">=14" @@ -6853,19 +6696,19 @@ } }, "node_modules/css-loader": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.8.1.tgz", - "integrity": "sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.10.0.tgz", + "integrity": "sha512-LTSA/jWbwdMlk+rhmElbDR2vbtQoTBPr7fkJE+mxrHj+7ru0hUmHafDRzWIjIHTwpitWVaqY2/UWGRca3yUgRw==", "dev": true, "dependencies": { "icss-utils": "^5.1.0", - "postcss": "^8.4.21", + "postcss": "^8.4.33", "postcss-modules-extract-imports": "^3.0.0", - "postcss-modules-local-by-default": "^4.0.3", - "postcss-modules-scope": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.4", + "postcss-modules-scope": "^3.1.1", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", - "semver": "^7.3.8" + "semver": "^7.5.4" }, "engines": { "node": ">= 12.13.0" @@ -6875,7 +6718,16 @@ "url": "https://opencollective.com/webpack" }, "peerDependencies": { + "@rspack/core": "0.x || 1.x", "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } } }, "node_modules/css-select": { @@ -8085,11 +7937,12 @@ "optional": true }, "node_modules/esbuild": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.11.tgz", - "integrity": "sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.0.tgz", + "integrity": "sha512-6iwE3Y2RVYCME1jLpBqq7LQWK3MW6vjV2bZy6gt/WrqkY+WE74Spyc0ThAOYpMtITvnjX09CrC6ym7A/m9mebA==", "dev": true, "hasInstallScript": true, + "optional": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8097,35 +7950,35 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.11", - "@esbuild/android-arm": "0.19.11", - "@esbuild/android-arm64": "0.19.11", - "@esbuild/android-x64": "0.19.11", - "@esbuild/darwin-arm64": "0.19.11", - "@esbuild/darwin-x64": "0.19.11", - "@esbuild/freebsd-arm64": "0.19.11", - "@esbuild/freebsd-x64": "0.19.11", - "@esbuild/linux-arm": "0.19.11", - "@esbuild/linux-arm64": "0.19.11", - "@esbuild/linux-ia32": "0.19.11", - "@esbuild/linux-loong64": "0.19.11", - "@esbuild/linux-mips64el": "0.19.11", - "@esbuild/linux-ppc64": "0.19.11", - "@esbuild/linux-riscv64": "0.19.11", - "@esbuild/linux-s390x": "0.19.11", - "@esbuild/linux-x64": "0.19.11", - "@esbuild/netbsd-x64": "0.19.11", - "@esbuild/openbsd-x64": "0.19.11", - "@esbuild/sunos-x64": "0.19.11", - "@esbuild/win32-arm64": "0.19.11", - "@esbuild/win32-ia32": "0.19.11", - "@esbuild/win32-x64": "0.19.11" + "@esbuild/aix-ppc64": "0.20.0", + "@esbuild/android-arm": "0.20.0", + "@esbuild/android-arm64": "0.20.0", + "@esbuild/android-x64": "0.20.0", + "@esbuild/darwin-arm64": "0.20.0", + "@esbuild/darwin-x64": "0.20.0", + "@esbuild/freebsd-arm64": "0.20.0", + "@esbuild/freebsd-x64": "0.20.0", + "@esbuild/linux-arm": "0.20.0", + "@esbuild/linux-arm64": "0.20.0", + "@esbuild/linux-ia32": "0.20.0", + "@esbuild/linux-loong64": "0.20.0", + "@esbuild/linux-mips64el": "0.20.0", + "@esbuild/linux-ppc64": "0.20.0", + "@esbuild/linux-riscv64": "0.20.0", + "@esbuild/linux-s390x": "0.20.0", + "@esbuild/linux-x64": "0.20.0", + "@esbuild/netbsd-x64": "0.20.0", + "@esbuild/openbsd-x64": "0.20.0", + "@esbuild/sunos-x64": "0.20.0", + "@esbuild/win32-arm64": "0.20.0", + "@esbuild/win32-ia32": "0.20.0", + "@esbuild/win32-x64": "0.20.0" } }, "node_modules/esbuild-wasm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.19.11.tgz", - "integrity": "sha512-MIhnpc1TxERUHomteO/ZZHp+kUawGEc03D/8vMHGzffLvbFLeDe6mwxqEZwlqBNY7SLWbyp6bBQAcCen8+wpjQ==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.20.0.tgz", + "integrity": "sha512-Lc9KeQCg1Zf8kCtfDXgy29rx0x8dOuhDWbkP76Wc64q7ctOOc1Zv1C39AxiE+y4N6ONyXtJk4HKpM7jlU7/jSA==", "dev": true, "bin": { "esbuild": "bin/esbuild" @@ -8308,14 +8161,14 @@ "dev": true }, "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.18.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.3.tgz", + "integrity": "sha512-6VyCijWQ+9O7WuVMTRBTl+cjNNIzD5cY5mQ1WM8r/LEkI2u8EYpOotESNwzNlyCn3g+dmjKYI6BmNneSr/FSRw==", "dev": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.5.0", @@ -8464,9 +8317,9 @@ "dev": true }, "node_modules/fastq": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.0.tgz", - "integrity": "sha512-zGygtijUMT7jnk3h26kUms3BkSDp4IfIKjmnqI2tvx6nuBfiF1UqOxbnLfzdv+apBy+53oaImsKtMw/xYbW+1w==", + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, "dependencies": { "reusify": "^1.0.4" @@ -8494,28 +8347,15 @@ } }, "node_modules/figures": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-5.0.0.tgz", - "integrity": "sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", "dev": true, "dependencies": { - "escape-string-regexp": "^5.0.0", - "is-unicode-supported": "^1.2.0" - }, - "engines": { - "node": ">=14" + "escape-string-regexp": "^1.0.5" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "dev": true, "engines": { - "node": ">=12" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -9173,23 +9013,6 @@ "node": ">= 0.4" } }, - "node_modules/hdr-histogram-js": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hdr-histogram-js/-/hdr-histogram-js-2.0.3.tgz", - "integrity": "sha512-Hkn78wwzWHNCp2uarhzQ2SGFLU3JY8SBDDd3TAABK4fc30wm+MuPOrg5QVFVfkKOQd6Bfz3ukJEI+q9sXEkK1g==", - "dev": true, - "dependencies": { - "@assemblyscript/loader": "^0.10.1", - "base64-js": "^1.2.0", - "pako": "^1.0.3" - } - }, - "node_modules/hdr-histogram-percentiles-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hdr-histogram-percentiles-obj/-/hdr-histogram-percentiles-obj-3.0.0.tgz", - "integrity": "sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw==", - "dev": true - }, "node_modules/hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -9646,18 +9469,18 @@ } }, "node_modules/inquirer": { - "version": "9.2.12", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.12.tgz", - "integrity": "sha512-mg3Fh9g2zfuVWJn6lhST0O7x4n03k7G8Tx5nvikJkbq8/CK47WDVm+UznF0G6s5Zi0KcyUisr6DU8T67N5U+1Q==", + "version": "9.2.14", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.14.tgz", + "integrity": "sha512-4ByIMt677Iz5AvjyKrDpzaepIyMewNvDcvwpVVRZNmy9dLakVoVgdCHZXbK1SlVJra1db0JZ6XkJyHsanpdrdQ==", "dev": true, "dependencies": { - "@ljharb/through": "^2.3.11", + "@ljharb/through": "^2.3.12", "ansi-escapes": "^4.3.2", "chalk": "^5.3.0", "cli-cursor": "^3.1.0", "cli-width": "^4.1.0", "external-editor": "^3.1.0", - "figures": "^5.0.0", + "figures": "^3.2.0", "lodash": "^4.17.21", "mute-stream": "1.0.0", "ora": "^5.4.1", @@ -9668,7 +9491,7 @@ "wrap-ansi": "^6.2.0" }, "engines": { - "node": ">=14.18.0" + "node": ">=18" } }, "node_modules/inquirer/node_modules/chalk": { @@ -9705,10 +9528,23 @@ "node": ">= 0.4" } }, - "node_modules/ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true }, "node_modules/ipaddr.js": { @@ -10083,22 +9919,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", "dev": true, "dependencies": { "call-bind": "^1.0.2" @@ -10399,6 +10223,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -10458,9 +10288,9 @@ } }, "node_modules/jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", "dev": true }, "node_modules/jsonfile": { @@ -10838,9 +10668,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "version": "0.30.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", + "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -11094,12 +10924,13 @@ } }, "node_modules/mini-css-extract-plugin": { - "version": "2.7.6", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.6.tgz", - "integrity": "sha512-Qk7HcgaPkGG6eD77mLvZS1nmxlao3j+9PkrT9Uc7HAE1id3F41+DdBRYRYkbyfNRGzm8/YWtzhw7nVPmwhqTQw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.8.0.tgz", + "integrity": "sha512-CxmUYPFcTgET1zImteG/LZOy/4T5rTojesQXkSNBiquhydn78tfbCE9sjIjnJ/UcjNjOC1bphTCCW5rrS7cXAg==", "dev": true, "dependencies": { - "schema-utils": "^4.0.0" + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" }, "engines": { "node": ">= 12.13.0" @@ -12321,9 +12152,9 @@ } }, "node_modules/pacote": { - "version": "17.0.5", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-17.0.5.tgz", - "integrity": "sha512-TAE0m20zSDMnchPja9vtQjri19X3pZIyRpm2TJVeI+yU42leJBBDTRYhOcWFsPhaMxf+3iwQkFiKz16G9AEeeA==", + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-17.0.6.tgz", + "integrity": "sha512-cJKrW21VRE8vVTRskJo78c/RCvwJCn1f4qgfxL4w77SOWrTCRcmfkYHlHtS0gqpgjv3zhXflRtgsrUCX5xwNnQ==", "dev": true, "dependencies": { "@npmcli/git": "^5.0.0", @@ -12341,7 +12172,7 @@ "promise-retry": "^2.0.1", "read-package-json": "^7.0.0", "read-package-json-fast": "^3.0.0", - "sigstore": "^2.0.0", + "sigstore": "^2.2.0", "ssri": "^10.0.0", "tar": "^6.1.11" }, @@ -12581,12 +12412,12 @@ "dev": true }, "node_modules/picomatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", - "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", + "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", "dev": true, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -12615,14 +12446,10 @@ } }, "node_modules/piscina": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.2.1.tgz", - "integrity": "sha512-LShp0+lrO+WIzB9LXO+ZmO4zGHxtTJNZhEO56H9SSu+JPaUQb6oLcTCzWi5IL2DS8/vIkCE88ElahuSSw4TAkA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.3.1.tgz", + "integrity": "sha512-MBj0QYm3hJQ/C/wIXTN1OCYC8uQ4BBJ4LVele2P4ZwVQAH04vkk8E1SpDbuemLAL1dZorbuOob9rYqJeWCcCRg==", "dev": true, - "dependencies": { - "hdr-histogram-js": "^2.0.1", - "hdr-histogram-percentiles-obj": "^3.0.0" - }, "optionalDependencies": { "nice-napi": "^1.0.2" } @@ -12739,9 +12566,9 @@ } }, "node_modules/postcss": { - "version": "8.4.33", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", - "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", "dev": true, "funding": [ { @@ -12767,25 +12594,34 @@ } }, "node_modules/postcss-loader": { - "version": "7.3.4", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.4.tgz", - "integrity": "sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.0.tgz", + "integrity": "sha512-AbperNcX3rlob7Ay7A/HQcrofug1caABBkopoFeOQMspZBqcqj6giYn1Bwey/0uiOPAcR+NQD0I2HC7rXzk91w==", "dev": true, "dependencies": { - "cosmiconfig": "^8.3.5", + "cosmiconfig": "^9.0.0", "jiti": "^1.20.0", "semver": "^7.5.4" }, "engines": { - "node": ">= 14.15.0" + "node": ">= 18.12.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { + "@rspack/core": "0.x || 1.x", "postcss": "^7.0.0 || ^8.0.1", "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } } }, "node_modules/postcss-modules-extract-imports": { @@ -13114,9 +12950,9 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dev": true, "dependencies": { "bytes": "3.1.2", @@ -13376,9 +13212,9 @@ } }, "node_modules/reflect-metadata": { - "version": "0.1.14", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", - "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.1.tgz", + "integrity": "sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw==", "dev": true }, "node_modules/regenerate": { @@ -13665,9 +13501,9 @@ "optional": true }, "node_modules/rollup": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.6.tgz", - "integrity": "sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.12.0.tgz", + "integrity": "sha512-wz66wn4t1OHIJw3+XU7mJJQV/2NAfw5OAk6G6Hoo3zcvz/XOfQ52Vgi+AN4Uxoxi0KBBwk2g8zPrTDA4btSB/Q==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -13680,19 +13516,19 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.9.6", - "@rollup/rollup-android-arm64": "4.9.6", - "@rollup/rollup-darwin-arm64": "4.9.6", - "@rollup/rollup-darwin-x64": "4.9.6", - "@rollup/rollup-linux-arm-gnueabihf": "4.9.6", - "@rollup/rollup-linux-arm64-gnu": "4.9.6", - "@rollup/rollup-linux-arm64-musl": "4.9.6", - "@rollup/rollup-linux-riscv64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-musl": "4.9.6", - "@rollup/rollup-win32-arm64-msvc": "4.9.6", - "@rollup/rollup-win32-ia32-msvc": "4.9.6", - "@rollup/rollup-win32-x64-msvc": "4.9.6", + "@rollup/rollup-android-arm-eabi": "4.12.0", + "@rollup/rollup-android-arm64": "4.12.0", + "@rollup/rollup-darwin-arm64": "4.12.0", + "@rollup/rollup-darwin-x64": "4.12.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.12.0", + "@rollup/rollup-linux-arm64-gnu": "4.12.0", + "@rollup/rollup-linux-arm64-musl": "4.12.0", + "@rollup/rollup-linux-riscv64-gnu": "4.12.0", + "@rollup/rollup-linux-x64-gnu": "4.12.0", + "@rollup/rollup-linux-x64-musl": "4.12.0", + "@rollup/rollup-win32-arm64-msvc": "4.12.0", + "@rollup/rollup-win32-ia32-msvc": "4.12.0", + "@rollup/rollup-win32-x64-msvc": "4.12.0", "fsevents": "~2.3.2" } }, @@ -13807,9 +13643,9 @@ } }, "node_modules/sass": { - "version": "1.69.7", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.7.tgz", - "integrity": "sha512-rzj2soDeZ8wtE2egyLXgOOHQvaC2iosZrkF6v3EUG+tBwEvhqUCzm0VP3k9gHF9LXbSrRhT5SksoI56Iw8NPnQ==", + "version": "1.70.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.70.0.tgz", + "integrity": "sha512-uUxNQ3zAHeAx5nRFskBnrWzDUJrrvpCPD5FNAoRvTi0WwremlheES3tg+56PaVtCs5QDRX5CBLxxKMDJMEa1WQ==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -13824,29 +13660,29 @@ } }, "node_modules/sass-loader": { - "version": "13.3.3", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-13.3.3.tgz", - "integrity": "sha512-mt5YN2F1MOZr3d/wBRcZxeFgwgkH44wVc2zohO2YF6JiOMkiXe4BYRZpSu2sO1g71mo/j16txzUhsKZlqjVGzA==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-14.1.0.tgz", + "integrity": "sha512-LS2mLeFWA+orYxHNu+O18Xe4jR0kyamNOOUsE3NyBP4DvIL+8stHpNX0arYTItdPe80kluIiJ7Wfe/9iHSRO0Q==", "dev": true, "dependencies": { "neo-async": "^2.6.2" }, "engines": { - "node": ">= 14.15.0" + "node": ">= 18.12.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "fibers": ">= 3.1.0", + "@rspack/core": "0.x || 1.x", "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", "sass": "^1.3.0", "sass-embedded": "*", "webpack": "^5.0.0" }, "peerDependenciesMeta": { - "fibers": { + "@rspack/core": { "optional": true }, "node-sass": { @@ -13857,6 +13693,9 @@ }, "sass-embedded": { "optional": true + }, + "webpack": { + "optional": true } } }, @@ -13905,9 +13744,9 @@ } }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -14250,17 +14089,17 @@ "dev": true }, "node_modules/sigstore": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.2.0.tgz", - "integrity": "sha512-fcU9clHwEss2/M/11FFM8Jwc4PjBgbhXoNskoK5guoK0qGQBSeUbQZRJ+B2fDFIvhyf0gqCaPrel9mszbhAxug==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.2.2.tgz", + "integrity": "sha512-2A3WvXkQurhuMgORgT60r6pOWiCOO5LlEqY2ADxGBDGVYLSo5HN0uLtb68YpVpuL/Vi8mLTe7+0Dx2Fq8lLqEg==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.1.1", - "@sigstore/core": "^0.2.0", - "@sigstore/protobuf-specs": "^0.2.1", - "@sigstore/sign": "^2.2.1", - "@sigstore/tuf": "^2.3.0", - "@sigstore/verify": "^0.1.0" + "@sigstore/bundle": "^2.2.0", + "@sigstore/core": "^1.0.0", + "@sigstore/protobuf-specs": "^0.3.0", + "@sigstore/sign": "^2.2.3", + "@sigstore/tuf": "^2.3.1", + "@sigstore/verify": "^1.1.0" }, "engines": { "node": "^16.14.0 || >=18.0.0" @@ -14372,16 +14211,16 @@ } }, "node_modules/socks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", - "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.1.tgz", + "integrity": "sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ==", "dev": true, "dependencies": { - "ip": "^2.0.0", + "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" }, "engines": { - "node": ">= 10.13.0", + "node": ">= 10.0.0", "npm": ">= 3.0.0" } }, @@ -14929,9 +14768,9 @@ } }, "node_modules/terser": { - "version": "5.26.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.26.0.tgz", - "integrity": "sha512-dytTGoE2oHgbNV9nTzgBEPaqAWvcJNl66VZ0BkJqlvp71IjO8CxdBx/ykCNb47cLnCmCvRZ6ZR0tLkqvZCdVBQ==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", + "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -15071,12 +14910,6 @@ "node": "*" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -15389,9 +15222,9 @@ } }, "node_modules/undici": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.2.1.tgz", - "integrity": "sha512-7Wa9thEM6/LMnnKtxJHlc8SrTlDmxqJecgz1iy8KlsN0/iskQXOQCuPkrZLXbElPaSw5slFFyKIKXyJ3UtbApw==", + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.6.2.tgz", + "integrity": "sha512-vSqvUE5skSxQJ5sztTZ/CdeJb1Wq0Hf44hlYMciqHghvz+K88U0l7D6u1VsndoFgskDcnU+nG3gYmMzJVzd9Qg==", "dev": true, "dependencies": { "@fastify/busboy": "^2.0.0" @@ -15696,6 +15529,412 @@ } } }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, "node_modules/vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", @@ -15753,11 +15992,10 @@ } }, "node_modules/webpack": { - "version": "5.90.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.90.0.tgz", - "integrity": "sha512-bdmyXRCXeeNIePv6R6tGPyy20aUobw4Zy8r0LUS2EWO+U+Ke/gYDgsCh7bl5rB6jPpr4r0SZa6dPxBxLooDT3w==", + "version": "5.90.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.90.1.tgz", + "integrity": "sha512-SstPdlAC5IvgFnhiRok8hqJo/+ArAbNv7rhU4fnWGHNVfN59HSQFaxZDSAL3IFG2YmqxuRs+IU33milSxbPlog==", "dev": true, - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", @@ -15959,7 +16197,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -15976,7 +16213,6 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", "dev": true, - "peer": true, "peerDependencies": { "ajv": "^6.9.1" } @@ -15985,22 +16221,19 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "peer": true + "dev": true }, "node_modules/webpack/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/webpack/node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", "dev": true, - "peer": true, "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", diff --git a/desktop/package.json b/desktop/package.json index c4d4fe498..540ebc87a 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -29,16 +29,16 @@ "lint": "ng lint" }, "dependencies": { - "@angular/animations": "17.1.2", - "@angular/cdk": "17.1.2", - "@angular/common": "17.1.2", - "@angular/compiler": "17.1.2", - "@angular/core": "17.1.2", - "@angular/forms": "17.1.2", - "@angular/language-service": "17.1.2", - "@angular/platform-browser": "17.1.2", - "@angular/platform-browser-dynamic": "17.1.2", - "@angular/router": "17.1.2", + "@angular/animations": "17.2.3", + "@angular/cdk": "17.2.1", + "@angular/common": "17.2.3", + "@angular/compiler": "17.2.3", + "@angular/core": "17.2.3", + "@angular/forms": "17.2.3", + "@angular/language-service": "17.2.3", + "@angular/platform-browser": "17.2.3", + "@angular/platform-browser-dynamic": "17.2.3", + "@angular/router": "17.2.3", "@fontsource/roboto": "5.0.8", "@mdi/font": "7.4.47", "chart.js": "4.4.2", @@ -57,10 +57,10 @@ "zone.js": "0.14.4" }, "devDependencies": { - "@angular-builders/custom-webpack": "17.0.0", - "@angular-devkit/build-angular": "17.1.2", - "@angular/cli": "17.1.2", - "@angular/compiler-cli": "17.1.2", + "@angular-builders/custom-webpack": "17.0.1", + "@angular-devkit/build-angular": "17.2.2", + "@angular/cli": "17.2.2", + "@angular/compiler-cli": "17.2.3", "@types/leaflet": "1.9.8", "@types/node": "20.11.24", "@types/uuid": "9.0.8", From beac9350616c700c789bc2e3ee380be135bce8a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:45:31 +0000 Subject: [PATCH 86/87] [desktop]: Bump typescript from 5.2.2 to 5.3.3 in /desktop Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.2.2 to 5.3.3. - [Release notes](https://github.com/Microsoft/TypeScript/releases) - [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml) - [Commits](https://github.com/Microsoft/TypeScript/compare/v5.2.2...v5.3.3) --- updated-dependencies: - dependency-name: typescript dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- desktop/package-lock.json | 21 ++++----------------- desktop/package.json | 2 +- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 1d9c44abd..0eaeab18a 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -52,7 +52,7 @@ "node-polyfill-webpack-plugin": "3.0.0", "npm-run-all": "4.1.5", "ts-node": "10.9.2", - "typescript": "5.2.2", + "typescript": "5.3.3", "wait-on": "7.2.0" }, "engines": { @@ -6328,19 +6328,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/config-file-ts/node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/connect-history-api-fallback": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", @@ -15194,9 +15181,9 @@ "dev": true }, "node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, "bin": { "tsc": "bin/tsc", diff --git a/desktop/package.json b/desktop/package.json index 540ebc87a..91d2d23f1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -71,7 +71,7 @@ "node-polyfill-webpack-plugin": "3.0.0", "npm-run-all": "4.1.5", "ts-node": "10.9.2", - "typescript": "5.2.2", + "typescript": "5.3.3", "wait-on": "7.2.0" }, "overrides": { From dc5fc4ab9f633018ac7c9d42a89e771de3ce2570 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Thu, 29 Feb 2024 17:58:51 -0300 Subject: [PATCH 87/87] [desktop]: Upgrade NPM dependencies --- desktop/package-lock.json | 707 +++++++++++++++++++++++--------------- 1 file changed, 436 insertions(+), 271 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 0eaeab18a..630425501 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -250,6 +250,108 @@ } } }, + "node_modules/@angular-devkit/build-angular/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/@angular-devkit/build-angular/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/@angular-devkit/build-angular/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack": { + "version": "5.90.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.90.1.tgz", + "integrity": "sha512-SstPdlAC5IvgFnhiRok8hqJo/+ArAbNv7rhU4fnWGHNVfN59HSQFaxZDSAL3IFG2YmqxuRs+IU33milSxbPlog==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.11.5", + "@webassemblyjs/wasm-edit": "^1.11.5", + "@webassemblyjs/wasm-parser": "^1.11.5", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.9.0", + "browserslist": "^4.21.10", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.15.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, "node_modules/@angular-devkit/build-webpack": { "version": "0.1702.2", "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1702.2.tgz", @@ -950,14 +1052,14 @@ } }, "node_modules/@babel/helpers": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.9.tgz", - "integrity": "sha512-87ICKgU5t5SzOT7sBMfCOZQ2rHjRU+Pcb9BoILMYz600W6DkVRLFBPwQ18gwUVvggqXivaUakpnxWQGbpywbBQ==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.0.tgz", + "integrity": "sha512-ulDZdc0Aj5uLc5nETsa7EPx2L7rM0YJM8r7ck7U73AXi7qOV44IHHRAYZHY6iU1rr3C5N4NtTmMRUJP6kwCWeA==", "dev": true, "dependencies": { - "@babel/template": "^7.23.9", - "@babel/traverse": "^7.23.9", - "@babel/types": "^7.23.9" + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.0", + "@babel/types": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -978,9 +1080,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", - "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz", + "integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -2221,23 +2323,23 @@ } }, "node_modules/@babel/template": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.23.9.tgz", - "integrity": "sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", + "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", "dev": true, "dependencies": { "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.23.9", - "@babel/types": "^7.23.9" + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.9.tgz", - "integrity": "sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.0.tgz", + "integrity": "sha512-HfuJlI8qq3dEDmNU5ChzzpZRWq+oxCZQyMzIMEqLho+AQnhMnKQUzH6ydo3RBl/YjPCuk68Y6s0Gx0AeyULiWw==", "dev": true, "dependencies": { "@babel/code-frame": "^7.23.5", @@ -2246,8 +2348,8 @@ "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.9", - "@babel/types": "^7.23.9", + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2256,9 +2358,9 @@ } }, "node_modules/@babel/types": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", - "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.23.4", @@ -3135,9 +3237,9 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.4.tgz", + "integrity": "sha512-Oud2QPM5dHviZNn4y/WhhYKSXksv+1xLEIsNrAbGcFzUN3ubqWRFT5gwPchNc5NuzILOU4tPBDTZ4VwhL8Y7cw==", "dev": true, "dependencies": { "@jridgewell/set-array": "^1.0.1", @@ -3149,18 +3251,18 @@ } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "engines": { "node": ">=6.0.0" @@ -3183,9 +3285,9 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", - "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.23.tgz", + "integrity": "sha512-9/4foRoUKp8s96tSkh8DlAAc5A0Ty8vLXld+l9gjKKY6ckwI8G15f0hskGmuLZu78ZlGa1vtsfOa+lnB4vG6Jg==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -4077,9 +4179,9 @@ } }, "node_modules/@types/eslint": { - "version": "8.56.2", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", - "integrity": "sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==", + "version": "8.56.5", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.5.tgz", + "integrity": "sha512-u5/YPJHo1tvkSF2CE0USEkxon82Z5DBy2xR+qfyYNszpX9qcs4sT6uq2kBbj4BXY1+DBGDPnrhMZV3pKWGNukw==", "dev": true, "dependencies": { "@types/estree": "*", @@ -4880,13 +4982,16 @@ } }, "node_modules/array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4899,17 +5004,18 @@ "dev": true }, "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", - "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", "dev": true, "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "is-array-buffer": "^3.0.2", + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", "is-shared-array-buffer": "^1.0.2" }, "engines": { @@ -5038,10 +5144,13 @@ } }, "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -5458,9 +5567,9 @@ } }, "node_modules/browserslist": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", - "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", "dev": true, "funding": [ { @@ -5477,8 +5586,8 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001580", - "electron-to-chromium": "^1.4.648", + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", "node-releases": "^2.0.14", "update-browserslist-db": "^1.0.13" }, @@ -5840,14 +5949,19 @@ } }, "node_modules/call-bind": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", - "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dev": true, "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5872,9 +5986,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001581", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001581.tgz", - "integrity": "sha512-whlTkwhqV2tUmP3oYhtNfaWGYHDdS3JYFQBKXxcUR9qqPWsRhFHhoISO2Xnl/g0xyKzht9mI1LZpiNWfMzHixQ==", + "version": "1.0.30001591", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001591.tgz", + "integrity": "sha512-PCzRMei/vXjJyL5mJtzNiUCKP59dm8Apqc3PH8gJkMnMXZGox93RbE76jHsmLwmIo6/3nsYIpJtx0O7u5PqFuQ==", "dev": true, "funding": [ { @@ -5934,16 +6048,10 @@ } }, "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -5956,6 +6064,9 @@ "engines": { "node": ">= 8.10.0" }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, "optionalDependencies": { "fsevents": "~2.3.2" } @@ -6847,17 +6958,20 @@ } }, "node_modules/define-data-property": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", - "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "dependencies": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-lazy-prop": { @@ -7695,9 +7809,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.653", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.653.tgz", - "integrity": "sha512-wA2A2LQCqnEwQAvwADQq3KpMpNwgAUBnRmrFgRzHnPhbQUFArTR32Ab46f4p0MovDLcg4uqd4nCsN2hTltslpA==", + "version": "1.4.688", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.688.tgz", + "integrity": "sha512-3/tHg2ChPF00eukURIB8cSVt3/9oeS1oTUIEt3ivngBInUaEcBhG2VdyEDejhwQdR6SLqaiEAEc0dHS0V52pOw==", "dev": true }, "node_modules/elliptic": { @@ -7765,9 +7879,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", - "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "version": "5.15.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.1.tgz", + "integrity": "sha512-3d3JRbwsCLJsYgvb6NuWEG44jjPSOMuS73L/6+7BZuoKm3W+qXnSoIYVHi8dG7Qcg4inAY4jbzkZ7MnskePeDg==", "dev": true, "dependencies": { "graceful-fs": "^4.2.4", @@ -7827,50 +7941,52 @@ } }, "node_modules/es-abstract": { - "version": "1.22.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", - "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "arraybuffer.prototype.slice": "^1.0.2", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.5", - "es-set-tostringtag": "^2.0.1", + "version": "1.22.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.5.tgz", + "integrity": "sha512-oW69R+4q2wG+Hc3KZePPZxOiisRIqfKBVo/HLx94QcJeWGU/8sZhCvc829rd1kS366vlJbzBfXf9yWwf0+Ko7w==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", "es-to-primitive": "^1.2.1", "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.2", - "get-symbol-description": "^1.0.0", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", "has-symbols": "^1.0.3", - "hasown": "^2.0.0", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", + "hasown": "^2.0.1", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", + "is-negative-zero": "^2.0.3", "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", + "is-shared-array-buffer": "^1.0.3", "is-string": "^1.0.7", - "is-typed-array": "^1.1.12", + "is-typed-array": "^1.1.13", "is-weakref": "^1.0.2", "object-inspect": "^1.13.1", "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "safe-array-concat": "^1.0.1", - "safe-regex-test": "^1.0.0", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.0", + "safe-regex-test": "^1.0.3", "string.prototype.trim": "^1.2.8", "string.prototype.trimend": "^1.0.7", "string.prototype.trimstart": "^1.0.7", - "typed-array-buffer": "^1.0.0", - "typed-array-byte-length": "^1.0.0", - "typed-array-byte-offset": "^1.0.0", - "typed-array-length": "^1.0.4", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.5", "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.13" + "which-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -7879,6 +7995,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", @@ -7886,14 +8023,14 @@ "dev": true }, "node_modules/es-set-tostringtag": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", - "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", "dev": true, "dependencies": { - "get-intrinsic": "^1.2.2", - "has-tostringtag": "^1.0.0", - "hasown": "^2.0.0" + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -7975,9 +8112,9 @@ } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "dev": true, "engines": { "node": ">=6" @@ -8649,16 +8786,20 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dev": true, "dependencies": { + "es-errors": "^1.3.0", "function-bind": "^1.1.2", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", "hasown": "^2.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8688,13 +8829,14 @@ } }, "node_modules/get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" }, "engines": { "node": ">= 0.4" @@ -8900,21 +9042,21 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", - "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "dependencies": { - "get-intrinsic": "^1.2.2" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", "dev": true, "engines": { "node": ">= 0.4" @@ -8936,12 +9078,12 @@ } }, "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -8989,9 +9131,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", + "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", "dev": true, "dependencies": { "function-bind": "^1.1.2" @@ -9502,12 +9644,12 @@ } }, "node_modules/internal-slot": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", - "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", "dev": true, "dependencies": { - "get-intrinsic": "^1.2.2", + "es-errors": "^1.3.0", "hasown": "^2.0.0", "side-channel": "^1.0.4" }, @@ -9560,14 +9702,16 @@ } }, "node_modules/is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9762,9 +9906,9 @@ } }, "node_modules/is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, "engines": { "node": ">= 0.4" @@ -9838,12 +9982,15 @@ } }, "node_modules/is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2" + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9892,12 +10039,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", "dev": true, "dependencies": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -9906,6 +10053,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-weakref": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", @@ -10179,9 +10338,9 @@ } }, "node_modules/joi": { - "version": "17.12.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.12.1.tgz", - "integrity": "sha512-vtxmq+Lsc5SlfqotnfVjlViWfOL9nt/avKNbKYizwf6gsCfq9NYY/ceYRMFD8XDdrjJ9abJyScWmhmIiy+XRtQ==", + "version": "17.12.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.12.2.tgz", + "integrity": "sha512-RonXAIzCiHLc8ss3Ibuz45u28GOsWE1UpfDXLbN/9NKbL4tCJf8TWYVKsoYuuh+sAUt7fsSNpA+r2+TBA6Wjmw==", "dev": true, "dependencies": { "@hapi/hoek": "^9.3.0", @@ -10612,18 +10771,6 @@ "node": ">=8" } }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/log-symbols/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11466,9 +11613,9 @@ } }, "node_modules/node-polyfill-webpack-plugin/node_modules/type-fest": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.10.2.tgz", - "integrity": "sha512-anpAG63wSpdEbLwOqH8L84urkL6PiVIov3EMmgIhhThevh9aiMQov+6Btx0wldNcvm4wV+e2/Rt1QdDwKHFbHw==", + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.10.3.tgz", + "integrity": "sha512-JLXyjizi072smKGGcZiAJDCNweT8J+AuRxmPZ1aG7TERg4ijx9REl8CNhbr36RV4qXqL1gO1FF9HL8OkVmmrsA==", "dev": true, "engines": { "node": ">=16" @@ -11826,13 +11973,13 @@ } }, "node_modules/object-is": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", - "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -12017,18 +12164,6 @@ "node": ">=8" } }, - "node_modules/ora/node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ora/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -12552,6 +12687,15 @@ "node": ">=10.4.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.4.35", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", @@ -13244,14 +13388,15 @@ "dev": true }, "node_modules/regexp.prototype.flags": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", - "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "set-function-name": "^2.0.0" + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -13598,13 +13743,13 @@ ] }, "node_modules/safe-regex-test": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.2.tgz", - "integrity": "sha512-83S9w6eFq12BBIJYvjMux6/dkirb8+4zJRA9cxNBVb7Wq5fJBW+Xze48WqR8pxua7bDuAaaAxtVVd4Idjp1dBQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", "dev": true, "dependencies": { - "call-bind": "^1.0.5", - "get-intrinsic": "^1.2.2", + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", "is-regex": "^1.1.4" }, "engines": { @@ -13959,14 +14104,15 @@ } }, "node_modules/set-function-length": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.0.tgz", - "integrity": "sha512-4DBHDoyHlM1IRPGYcoxexgh67y4ueR53FKV1yyxwFMY7aCqcN/38M1+SwZ/qJQ8iLv7+ck385ot4CcisOAPT9w==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", + "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", "dev": true, "dependencies": { - "define-data-property": "^1.1.1", + "define-data-property": "^1.1.2", + "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.2", + "get-intrinsic": "^1.2.3", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.1" }, @@ -13975,14 +14121,15 @@ } }, "node_modules/set-function-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", - "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, "dependencies": { - "define-data-property": "^1.0.1", + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -14056,14 +14203,18 @@ } }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dev": true, "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -14305,9 +14456,9 @@ } }, "node_modules/spdx-exceptions": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.4.0.tgz", - "integrity": "sha512-hcjppoJ68fhxA/cjbN4T8N6uCUejN8yFw69ttpqtBeCbF3u13n7mb31NB9jKwGTTWWnt9IbRA/mf1FprYS8wfw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "dev": true }, "node_modules/spdx-expression-parse": { @@ -14321,9 +14472,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.16", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", - "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", + "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", "dev": true }, "node_modules/spdy": { @@ -15110,29 +15261,30 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", - "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "is-typed-array": "^1.1.10" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" } }, "node_modules/typed-array-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", - "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -15142,16 +15294,17 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", - "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", "dev": true, "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -15161,14 +15314,20 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", - "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.5.tgz", + "integrity": "sha512-yMi0PlwuznKHxKmcpoOdeLwxBoVPkqZxd7q2FgMkmD3bNwvF5VW0+UlUQ1k1vmktTu4Yu13Q0RIxEP8+B+wloA==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "is-typed-array": "^1.1.9" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -15979,10 +16138,11 @@ } }, "node_modules/webpack": { - "version": "5.90.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.90.1.tgz", - "integrity": "sha512-SstPdlAC5IvgFnhiRok8hqJo/+ArAbNv7rhU4fnWGHNVfN59HSQFaxZDSAL3IFG2YmqxuRs+IU33milSxbPlog==", + "version": "5.90.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.90.3.tgz", + "integrity": "sha512-h6uDYlWCctQRuXBs1oYpVe6sFcWedl0dpcVaTf/YF67J9bKvwJajFulMVSYKHrksMB3I/pIagRzDxwxkebuzKA==", "dev": true, + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", @@ -16184,6 +16344,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -16200,6 +16361,7 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", "dev": true, + "peer": true, "peerDependencies": { "ajv": "^6.9.1" } @@ -16208,19 +16370,22 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "dev": true, + "peer": true }, "node_modules/webpack/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "dev": true, + "peer": true }, "node_modules/webpack/node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", "dev": true, + "peer": true, "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", @@ -16294,16 +16459,16 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", - "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.14.tgz", + "integrity": "sha512-VnXFiIW8yNn9kIHN88xvZ4yOWchftKDsRJ8fEPacX/wl1lOvBrhsJ/OeJCXq7B0AaijRuqgzSKalJoPk+D8MPg==", "dev": true, "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.4", + "available-typed-arrays": "^1.0.6", + "call-bind": "^1.0.5", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "has-tostringtag": "^1.0.1" }, "engines": { "node": ">= 0.4"
-
+
@@ -16,10 +16,10 @@
-
+
- +
@@ -29,9 +29,11 @@
-
+
+
-
\ No newline at end of file diff --git a/desktop/src/app/focuser/focuser.component.ts b/desktop/src/app/focuser/focuser.component.ts index 43266c857..2a417c774 100644 --- a/desktop/src/app/focuser/focuser.component.ts +++ b/desktop/src/app/focuser/focuser.component.ts @@ -1,5 +1,6 @@ import { AfterViewInit, Component, HostListener, NgZone, OnDestroy } from '@angular/core' import { ActivatedRoute } from '@angular/router' +import hotkeys from 'hotkeys-js' import { ApiService } from '../../shared/services/api.service' import { ElectronService } from '../../shared/services/electron.service' import { LocalStorageService } from '../../shared/services/local-storage.service' @@ -57,6 +58,21 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { }) } }) + + hotkeys('left', (event) => { event.preventDefault(); this.moveIn() }) + hotkeys('alt+left', (event) => { event.preventDefault(); this.moveIn(10) }) + hotkeys('ctrl+left', (event) => { event.preventDefault(); this.moveIn(2) }) + hotkeys('shift+left', (event) => { event.preventDefault(); this.moveIn(0.5) }) + hotkeys('right', (event) => { event.preventDefault(); this.moveOut() }) + hotkeys('alt+right', (event) => { event.preventDefault(); this.moveOut(10) }) + hotkeys('ctrl+right', (event) => { event.preventDefault(); this.moveOut(2) }) + hotkeys('shift+right', (event) => { event.preventDefault(); this.moveOut(0.5) }) + hotkeys('space', (event) => { event.preventDefault(); this.abort() }) + hotkeys('ctrl+enter', (event) => { event.preventDefault(); this.moveTo() }) + hotkeys('up', (event) => { event.preventDefault(); this.stepsRelative = Math.min(this.maxPosition, this.stepsRelative + 1) }) + hotkeys('down', (event) => { event.preventDefault(); this.stepsRelative = Math.max(0, this.stepsRelative - 1) }) + hotkeys('-', (event) => { event.preventDefault(); this.stepsAbsolute = Math.max(0, this.stepsAbsolute - 1) }) + hotkeys('=', (event) => { event.preventDefault(); this.stepsAbsolute = Math.min(this.maxPosition, this.stepsAbsolute + 1) }) } async ngAfterViewInit() { @@ -93,27 +109,35 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { } } - moveIn() { - this.moving = true - this.api.focuserMoveIn(this.focuser, this.stepsRelative) - this.savePreference() + moveIn(stepSize: number = 1) { + if (!this.moving) { + this.moving = true + this.api.focuserMoveIn(this.focuser, Math.trunc(this.stepsRelative * stepSize)) + this.savePreference() + } } - moveOut() { - this.moving = true - this.api.focuserMoveOut(this.focuser, this.stepsRelative) - this.savePreference() + moveOut(stepSize: number = 1) { + if (!this.moving) { + this.moving = true + this.api.focuserMoveOut(this.focuser, Math.trunc(this.stepsRelative * stepSize)) + this.savePreference() + } } moveTo() { - this.moving = true - this.api.focuserMoveTo(this.focuser, this.stepsAbsolute) - this.savePreference() + if (!this.moving && this.stepsAbsolute !== this.position) { + this.moving = true + this.api.focuserMoveTo(this.focuser, this.stepsAbsolute) + this.savePreference() + } } sync() { - this.api.focuserSync(this.focuser, this.stepsAbsolute) - this.savePreference() + if (!this.moving) { + this.api.focuserSync(this.focuser, this.stepsAbsolute) + this.savePreference() + } } abort() { @@ -121,7 +145,7 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { } private update() { - if (!this.focuser) { + if (!this.focuser.name) { return } diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 35ba98849..22012de3d 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -1,6 +1,7 @@ import { AfterViewInit, Component, ElementRef, HostListener, NgZone, OnDestroy, ViewChild } from '@angular/core' import { ActivatedRoute } from '@angular/router' import { Interactable } from '@interactjs/types/index' +import hotkeys from 'hotkeys-js' import interact from 'interactjs' import createPanZoom, { PanZoom } from 'panzoom' import * as path from 'path' @@ -386,21 +387,12 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }) }) - window.addEventListener('keydown', event => { - if (event.ctrlKey && !event.shiftKey && !event.altKey) { - switch (event.key) { - case 'a': this.toggleStretch(); break - case 'i': this.invertImage(); break - case 'x': this.toggleCrosshair(); break - case '-': this.zoomOut(); break - case '=': this.zoomIn(); break - case '0': this.resetZoom(); break - default: return - } - - event.preventDefault() - } - }, true) + hotkeys('ctrl+a', (event) => { event.preventDefault(); this.toggleStretch() }) + hotkeys('ctrl+i', (event) => { event.preventDefault(); this.invertImage() }) + hotkeys('ctrl+x', (event) => { event.preventDefault(); this.toggleCrosshair() }) + hotkeys('ctrl+-', (event) => { event.preventDefault(); this.zoomOut() }) + hotkeys('ctrl+=', (event) => { event.preventDefault(); this.zoomIn() }) + hotkeys('ctrl+0', (event) => { event.preventDefault(); this.resetZoom() }) } ngAfterViewInit() { diff --git a/desktop/src/app/mount/mount.component.ts b/desktop/src/app/mount/mount.component.ts index 2434fa01a..baed54123 100644 --- a/desktop/src/app/mount/mount.component.ts +++ b/desktop/src/app/mount/mount.component.ts @@ -391,7 +391,7 @@ export class MountComponent implements AfterContentInit, OnDestroy { } private update() { - if (this.mount) { + if (this.mount.name) { this.slewing = this.mount.slewing this.parking = this.mount.parking this.parked = this.mount.parked diff --git a/desktop/src/shared/services/browser-window.service.ts b/desktop/src/shared/services/browser-window.service.ts index 7c562b289..c1961d0ad 100644 --- a/desktop/src/shared/services/browser-window.service.ts +++ b/desktop/src/shared/services/browser-window.service.ts @@ -40,7 +40,7 @@ export class BrowserWindowService { } openFocuser(options: OpenWindowOptionsWithData) { - Object.assign(options, { icon: 'focus', width: 360, height: 203 }) + Object.assign(options, { icon: 'focus', width: 348, height: 202 }) this.openWindow({ ...options, id: `focuser.${options.data.name}`, path: 'focuser' }) } From 8d9a9c93bb55244dbfce3f6f0cd5968cfb5dc866 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 24 Feb 2024 02:39:35 -0300 Subject: [PATCH 51/87] [desktop]: Adjust window dimensions --- desktop/alignment.png | Bin 23214 -> 23242 bytes desktop/app/main.ts | 8 +++-- desktop/camera.png | Bin 38676 -> 38958 bytes desktop/filter-wheel.png | Bin 14383 -> 14262 bytes desktop/flat-wizard.png | Bin 19634 -> 20931 bytes desktop/focuser.png | Bin 12447 -> 13065 bytes desktop/framing.png | Bin 16735 -> 18502 bytes desktop/guider.png | Bin 28193 -> 27628 bytes desktop/home.png | Bin 37346 -> 37319 bytes desktop/mount.png | Bin 40405 -> 38784 bytes desktop/src/app/app.component.ts | 16 +++------- .../filterwheel/filterwheel.component.html | 2 +- .../app/filterwheel/filterwheel.component.ts | 4 +++ .../shared/services/browser-window.service.ts | 20 ++++++------ .../src/shared/services/electron.service.ts | 30 +++++++++++++++++- 15 files changed, 54 insertions(+), 26 deletions(-) diff --git a/desktop/alignment.png b/desktop/alignment.png index 69d73ce8cd6676f547cbf6e98bcd31fbd474cee8..02e17291f180c1c8d2acef5027996530cc12b17b 100644 GIT binary patch literal 23242 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 V?h9diqL(zu+r!uW+NRKyKLJ|aTVVhI diff --git a/desktop/app/main.ts b/desktop/app/main.ts index 908a9cf2a..62c492189 100644 --- a/desktop/app/main.ts +++ b/desktop/app/main.ts @@ -100,7 +100,7 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { } } - const height = options.height ? Math.trunc(computeHeight(options.height)) : 420 + const height = options.height ? Math.trunc(computeHeight(options.height)) : 416 const resizable = options.resizable ?? false const icon = options.icon ?? 'nebulosa' @@ -419,8 +419,10 @@ try { if (!window || (!serve && window.isResizable())) return false const size = window.getSize() - window.setSize(size[0], Math.max(0, data)) - console.info('window resized', size[0], data) + const maxHeight = screen.getPrimaryDisplay().workAreaSize.height + const height = Math.max(0, Math.min(data, maxHeight)) + window.setSize(size[0], height) + console.info('window resized', size[0], height) return true }) diff --git a/desktop/camera.png b/desktop/camera.png index 19e2ab0f267aef1ef425ce85df4785315b45c8d7..dda83d68ee9117f2ace53297a67370882a75ffd1 100644 GIT binary patch literal 38958 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 literal 38676 zcmbrmbySsI`!x!Ph$tZ#h(hW+3fQWR72na|>HwaQvBHhy6-RIix zJAUJwasD{x8{hYg;gF4c?|a>AT`{jY=lz5!%1dHm5Mv-AAz?~Oy;Md*LjC}MrqI#g zH>b3hlkgX+lbEzBI{fF2ZW0QwiJZkXomK43oZXBZO_9v)>}^e1oQxe!P3@d4?45UR zH3`F;=n!vu;b>~)Y-w*tscLC!iezGMOUcedX=P+b$-&OSMfsG6pZzKS(`O6W&-;*& zD3PRJimJM&ZccmXsxDnO@AYr8+q4!j|IVR&LRXYnSHotRY-(mUSyhtJx z>g!3}@rXYV{P^#mA?G_v^mh)CUyu9z5kFYtBEQ5z6}7;B@ElbXC(!J{|Iy1uvt{yb zo<9@c zc`DRAlk^Z)0PlIkpU!Zl$1q*vZ@$82+Lmz7-St}AS7b%T@DqmGBHnWGbnA~#_m!iz zjF#?E@1oV4f2W~d;Azw;{U}7wDaBVBcBUZIZ>|#+^D(|9oi_SGHi`r7aajM@p$-O- zCBtQ5zar+vZHBr==jZ9CO|yYb%bxKZ`s1*go~XjQ60e2A*?zwn zgKi=hJQXv{FD6?8%oCZk4yy}-GYUZ)p*7xz{h}pK$ZYkcpT1VAxGz+!p^jw#LBsN{ z3+&By<0!pHQ=HyfPEYWL&QQ?lhVQmgjO&HU?UxTG*($FMBsILu<*KDSx6+U^jm`_0 zrO!=8Ca$CRPT$ojb?G4s3EQs6CpbC}DvxYOJzk$ ze@KM0*=2sJ+vv>~&(xiARzP9YJTtS{mpw4 z&eS>Vo(2D(p4`KsD;_&xnq!CPw>%Y$x6U?%x#s9{);S{LNR~N;kxH9Dt1rQXzb|P_FqSrjTcB~s2uwP48J7W*yoe5J&e5# z8@SaNA2B{(B9_=(WcXE{myzsn@rT~tM9z%a-uLLi!mV;rK4%Hk2pz@d&*R?Z^PJ=n zYS*1{f5WdNuA{|%p`~s@^JB3! zoAgfyhu2Tp`^uIK;z3na3bCc zbWK0KqHlj`La+8*U||%oS7^6+CVWC!Oy{=Ho87-qd#IFtC65|YkoW!j*RNlHEoaE* zmc59Q89$w;W|oSaKP$dQ|L;P-tbLnb*3*{TQE&5FozwI<< zWYwHyA*;*i8)N0ZO>yp+=D_c>H_@i&#y*yt_|jhgNYpPs&N6DDIJmjxmX?0Z&u2{O zj+3EfWo5;~$FI~*XC6`#JU&Yhq)7kuMWKx7?1bT4rBHVkn;mUkP_oG{pRbhYM?_`g zr^N)Tbw0E^EJ1wHLcdAl_hu{}VI5anxu2N+epnXzQO~D>)5dZb$x)?1>h)S|^k?mB zvi_!Lp_ixTNmK8B-g@~co)Z0;2j~0}PqhG&YNm?2{TlOWe^yTzw&92*)B?MuO~Qgd z8%L2h!{v^Rc8)U*+h!&-iz^B96HX}9Ejq_UtV%(&=wd$W4Bj5z!U3#x3!}NkrhUdY zZ?&A(N{+SgN%2{zd7K`_tEF^IuD#b()k&)`TvlP0vrFoiN5%2lUu%8nm-ajq7cVYX z^Qg-)3QypHu+cM4<7rV04*`N77Tq3H*ZX|^;`p(8C>AFr-g{;otQ(FS0v7mX?WkY4 zL_PNTg)b{Zm7KQcH!lnve*H1TvQfV8lT^he>u%(uA3h+{zCce9I6 zk)F|f8534tx{CfghK*CQCbVkZliccjch^Aj;rwRc{w4qN&1zpy=37*bSfwtCeXk3x z9^yx#%G$ko75B9Cxq@%^yNYT!gkv5gIQ=@7OL8dSNH^L#h;DgjT7Nd|eaLBGxRG)B z^KWTssYJ$0d!KRUG}k+_wk-J&x6}=`Y}RN>C_hMOWt=rJ(1Qh)`|%bQzc+s_sQjKS zkn;PCsqQ0*^QVPCm1%2ZG;5KG=SL+8XN$A;`@hl}4TlHCI`WR}57txT7RAv%RIjR~ z-!$DiJ>J@rB!0$w`&_wvO-MiO;&I5W?JtsZW{nd`a>sqeW2uuNciIUxPbTiw?~Y(i z(aTfnVgzrJ)t}U&lUKzhE0-$JWDegt=lp&(nN?4m!IEF%pPWp5AMHh!UlkK9HpMFc zjhNDj@|>)@fBL-REev|vOSW>}ihCV3?8BZaK@a;@PpONZJWpxfufo+_8&6AOyG&yk zH@N)KIPkm%i+$}++@s7vWM#5pjy8&mD$xWd@|C=@vkRQvBh}KCeb!Ig$fx+G4*3}0 zWN7Q?@D0{HzA@(3W}*h^#O}FWb6%Wu>t`4(gqUw8P|W2#mZ3a;%`5&|3H=teHQQV= za`eG3MT31@g*j4ABm<()=qoe1JG9H;1>ViO$kp@|*9J$AKG#I>*}tNGjuzbVFqIcu zUaruy@R`4{E#I&4u5l}+&M~jMyp12QZyHitgDQ-EDiV-~_rw?wgVg9Path81j{rF( zi>n&l1#s6(g%S$L?hiASKl=an%lIE9GBProN!X$|sB`#p%gbn?xc~XqSN|v9y5Fv0 zDV(PoE|V=r7dzcK%@r|V@v?Z-m?LR3?Gd0)oxhk&Z>6%8hpdj|hy;;LIEbt&i1SqO zS`fFl5FGbhl~FCA`}}C5qxtQv7@2HRVeiD=6rR~{He52<%1T;VmQ`2x-ce5F+i4GCD?D%cXJuun#VN_iU}$M+m0OQSP1m`V*-i5Y-zR#foQF!u%^hbuSvim;iCt+o zEu*ZA+u(U5mZ!>L`Ip9ZYocwrHzkSP2t_#$uSQp1ObkU@TKe?-e7~1x)?t598ouQ3 zL!i_xKb)!x^W2!gE?{}-5Y=YC^9lKcJ}w@`rTZ2E;-WRhGk=l7Yu8A%9xs) zKO`l6UtG*a9Elbr_Vcr70DICaSp1L`H&R$w82l6-9!@CYD;O0OrLkqZw7Kcs5WR1;r$p9+fS;6}k1A7=gf#GS^AsQrD%ZbvpYw(Z&t zR^Q7#$&azI#XCs1=lHos78Drc=3%g@@u(f3!M*EPLwe`Voq>Bw)vPSaC^g#A1xzqO zm6~?rZ`Z7lLr8^SQ!JnEEx3Dmak}psvg%eN4XL~6t?g52xVUgzj~1D3j{Ue#%KPC( z2-chB9-{93FJGwO(0i$)1zXx82yAoc(F^l3$^9^bzNn#e#K9#of>0XtXV)+A?Xdqd zVd>6(s{Dc>g_FFzuk1)xp>3OhgiGA&z zZzU;j-Q>;or~3_D(j4`kvNb- zyrH3|Usze`5V<*1fYXKC-JGn7hS(mKbzAI=IozHm$y5Cu_K^Pc`eNtq{re`n|9*O3 z9gX((?R3Ua19~;-PZxTY5E2w*#7@ZTwyj%s@)&&-lAik%?=0@c?dk8=`@0vv>v*fQT7m>nFO5=L^XYG`Q1O9r3Jne4N<_89pbW0C&}N2Z~q^pByI+@A}f zP}bJ|Ep#%~x;xi;w%0+C#P7mr-j`~$H{a&&?!LQO*3aR0?fu#H^kdLHf-j0s+)w91 z)@GYTyiT?&zLF3V%jC+ZxnFEm!4xF8-${+wk$^kcrDna!B%ZH~jEq#()t@o=bGbMh zidwu+Ng+|r8?s8IK#G(IC@bS6VAKDalhYo>XQS(&$Yq6?@mjy+ z-`{Lm2;*A>1Ox?Jm}s{|E_ZK5i(FT$A?4LAa1ci#*^HGS$H|Dp+hL(*OTXiKk~l*m z2-xP~jO?4Pf@5PJDk&-9;o%wGKmgIYNZp1~*z{*NTOB-~D!W+lW&re2H_G~%D{s|-Fd>erQ%pif`;TZ93 z2EJMH#k=$$Gcuy9s(6Hjh2tre2?Wcvx8x`U?If~IgHpTYb9@YG+%UaMW*lo?S%&Cx zzbaHW<|4KgPMk>AF1)`uHzz78`gX}hN$G)r>t=IrZ{+;^>jJ6d4+3ggVPIhF z?(UX&7Tcv3tfYHI3HzKY>gx8K9gGM)OHEDHtgw1?u-t15l{uBqDR@-hD-wnls7u7X zb-JvQSp&N>UORg~?@+M2Zfast2-^_3UPIZKav9Y_Y|Ds_{SWI=*6q3-;qK!)w;lYS zFD#zI9$a2}zoL5xH&k6$r}IwI#igR5Y}5vB2nm94?XqGPEl*C#~5T zH%{L}108M=Or<ynU@3mR{6MM~4)OhJ)^SaaoaKpaN#Fo*;hV_fwq*hn+ z@;k3K%d#~D*45Pw^Fs`LijV&?x`EhwYU*3x$kwVDT1$q8hCal^{ESr)?#hZ54ll62 z`r;OXMaJvAii1QipGrwd*<8B$+n)}7w171fx~{IS?h|I_`s{#?noW~s9|sYS2`6TQ zBSsPW%Y>b0%r?Ve_grLofd6&deZM11R7@AOwQybJwl_!%ZYsMNB( z5e?{?znz;RHIOfv*kbZe-6bEr}~B#SDAFNv=Bc zb8v8Uc6J*63Vm?6IUaj>cu2QYz+n3jW=KSXfL?*rpx|D0)$n|dH zQbkGuLBH@3-;Dm-=e9V=V}v)B1xsr(u)|YRx^-JOj)nNo;naIz=YD6t2>G+-jOYpf z!Na#KEiDgsW-|fgp+RT=i=g15@Le&w)aRC)SBCxfP+^eR}DvBH`=p0H03M?0oa zpHp5Q6MMlZA|m3RXG5cYO2|tp(X0U|0p4M{i%5r#ru4*)fl#PY)GG)Iyx)!E^0oA(YM!$SP|@ zoEUd8s~*zjZksPG_iI1Zh#agw+uc;jP}0+Dqc`Zu`Ck8RFVgM>qRo?qA~|lTRMHYLf)X;Vbr;Jn*RrgPa7|zXmVGVt|n)}eZG0c z+dw&mL42Mzc5p)NTi0@A5rg!)xUut{1C!;d+iP~hO=VnmpyTczd$j0O3zbVDL5QiU zs)p7PiI)7d;8Ipn8u&Lj2ayi-&dSP4oAv`#8u;N`etxZKx+a00Jj+Prs0qh3E@$T5 zmfp^Eg-7?qoWdlWVPTnCFOeSnXV`#^`2T;Yy~+0hI#cY&k7DBD2O<2&$HzJO`Qb1V z<9EfrqI*%Oo~4}kq02;y22(+w8jxz;e~3hhL(eNrkFh#&l+c${nQ|%5UP?+L0`vTE z?M0Q{^m<`JMn?Zcg*Az{VZKVPyz}}{m|B5)=HS}yKA^QPEz2~5kxB)_fZVwlEUIg2 z9>;bu7t6}X{8BlB>0tlAD6vL2O1m)U`}fUSyZvGsCpR}gvLZjh0#5Tjc9TvVNXRkh zYUcgvw0wNy`vio9p#X{BzI_WVY>ZSav%LHh^uE78j3ptm!@|Q~L%U5CaNACP7ph&@ zK0I6@{WA-|Ms~zT0kN~R#NPbCGlVe0D5k)HP>B+y)9!V`AkdmC7 zOlLo-`}5~d!0^hNno_E&cxPv4Fi>naUf97^bWA8ly#%c)lHtOR+fy|pjW?^S7En?d zo;y%7!!4(0%q%kQ!tn2 zA%UG&|Gb5I{rmTCDABLd1(Qlj*r}+gW4f0tDkeNHRwpW$+-5xJV`5@>1q5PKQX+D5 z>4iKGDVdlG#y#ve%MOl?+6D%Gh_eM(3530TCzIV`!l8FUXn}==_57Z?QBtoNi~us( z9~o3~O5pekKC7NIH8qLA4KG~mG!-WVhlJe04MUBTXuX9k*qpCcfRrfgla!*)tb+u*qp?X|o5ogoK1@oY#~Kus;yM zy!ZCj9sh|46&RJE8N+N?WhGoh$IF{oX5K#`K++3f<&BF=HbjQ_--3(xcAsi@0$>pX z0|PcQ^+~^f%Xl7dN++=D@p>LU@wqsrWn+uZ@Vh25H#djF>S~W9L53D-0=NN+E82a+ z=kD&+!;zuU(b&GezQ5YqjGm(HnuI%ekg(7Iq2eQ9UM3NvKOz`z4QpW`-&NeGe)pkwpTfIEC{+oZ6>!;MkwY~?WZ zf(q38aWX-Wf{?Qhs3krt=MnNdKY=JWfw}m*ULAggwp^3%m4k!BXLtv60|4hRHD~Ko z*+DypH8))ylhD%API;`P0UlD((&}Psx_q4^aPfQLE;>3T6%|U5*xP`BTOCnk)HE~@ z2rncg&?KWsf27_6?C82ZW$DuBd9)s+%i00Mc8zWy^g{*~7Fs~=iHU@8fv&Euui4qi ze_%qcZ)iZ^pU01plU}t$M;U*NZ+klmaQ((;ag~Sg<>lq-bR7>8V1`sd zkJ6cw$Bc~OLqn?Fnl7+*)H`>w6rb$w?HK`_f4=3tMX!+l3xaWdiYQV70YCoz3@Ahi zRnGfbTRX8|cY1X)3(0wUdWu6t)OCGzK_uYH4Eqa#@^^3$@FOZJs-n7jdlZ>~;uE|` zU)MB#moLU0QBXs=cen%vr#M2b<>jM#ch;-lEPMk=ptd#{P|T#rh{)rxm#_2sYV}!W z?pcqO7{NEf%BxRiykJy=B8F~nzhf=mJw2V4r;_WqIrj8;XV$>Jrto>Da$b>#2sbx3 zoG+*M>2ASwBq`rVqt;+OZWbsBxw*M&!!Pyq$zcQpm;-G;pt!hrsn2$$FYQ-*J5|DH z`#w~lXEtNa02#s{RE33S9G@bu4(4Lt!6G#TbOrPTECmVZf`)8ui;a;&$RJMwD|>r; z+=maxtKU%HzI{6|Fz~Y~Bd`;5xf#B%A)4Y32p&BW*U}<_>q5o;ldF({7JbL+mZ!J3 z;N81-;nJmkH@=NG*Io$Z)v>(39uCTg$BOZ@l<{)Qhk1ExY^fsTCq@y`k1>2FCLT`1rx_2GmJ@#<*~Z=Q5*KG#GX|ySt6A&X3?|y3d-D zYopkqPtC&~et{G=F);zai(s9AoYJ@|%K7>=^qamnss8DuHc$ogb8`c$o{?%z(Aq`{m>S*ps1zsL zq3Za9c9)ct1YOCQpG;g_9O4eC8@mL?#FiGZ_Y%}F+QbXU7#J9wpX^}!>#|mToo@8; z>P|Cx;ppVF1{n<-*WT3?_Wu2Sz~VtcLGg8ifBz~YO;p-i=LL8E{#`n?G{K(^#Sd5q zz!slAeNt3XGGZtMK=cphWe*RJhL2h=R;029{;jQr0anbCh%6;7&@MzPNuBNKc?IDC zGfDOO8!t~!Z8BphPf%PRQ&R_!3wi!2)Zu}GWGZ_^ zsdw!IwA-@+mEwxqon=KYcTh{Xu6D=S7&Z(L z!+kP-ywCb;=tb9h4IH2ERwR3+)&IrBq-?rAX955-<#RF(s_h%NCQB zwy`7Ky}b?V`>-hzlhQ?(qOvp}>HI!JV%toVcdZLSrFU_0$-*>tn|gBxGH!FWDI+u# z{gX^~_E!E6!OC9*MS#;tMSSbKHb#egdA{bU#bKuHKq&j3uhGLCvIbQUvasEd9ok$w zq{SO2r!1Iqp4%#R_4QRInN2HhZcS8_)od-T^P6ym6dt^RG9xG`Xfawu28V)($`$}e z>lZ-HexJBKY{d;zBQPiPi;E`1qBKz5mfh-$4&7^N?M}1{@e9>uot?R$6E7|;aj&^2 zJCc)FDs^8>!I z1YWhKrp9@5EJi+!KPD+j8ZsDy_J^)_uNfx{rtw_w-o5+Y_d~q^F~A`KM1(>*_^;AG zJCFzu&&2YyP$0d1->>4}DKH<9mQYS_Sp~Gl&4xghwY0XfxLrbD0UG)>7jX?_^4#29 zye)tN-QtiWUDjNH%&EdY6YD;1Zf>=1J9d0n71}<%O-=1l4Mn3L1}uJcblk~SzB_~# zS(`r)e_eDNIM~4G;a5 z)Y2MPt9y8HaiOywR15_anBZ0bF_7Dr=NrXx+xTApnvu8b59DiIw`5>npmxEwmiSz_ z790WD+YIkz5wb#p8cE1*hy-PIab<-%wu_zcKB<7~`ya*~+T>guDZRi~4p!M4(7j50 z4jT zE8E)E76c!C{VWBB3XmTjDJb-}H{sk2j+tvHD~tL13dhnb%Km}J#J$}B`w!JxC=DaHm<0sC|>`m3ykx?-^mrx{b&4`dLtP-A_U5ST7e-{5e7y^tKr5qj->ZcL3k#x z9Jm2^1HVC;&`rc+M+&(e+Z!=<1c)pkn_#%Gy|))LPR8_rFEr=dhSm@#f$NOauOA#7JOeV=h8$>Aseozk4M?EK8C}@lk)A`6GR2*GJyvC@$k^u%Y3c!Z!#FDh*h+;J*RnUcxe2&f@HE^ z&;JU89mpL(TPq;qFhPf=2Flgg^1e0EZ!llYxtc#q(Xn~*uZ_wD9@2hO)=# zJVBG^d^mHq+L8E1QlXx$Trs982F${_ zir^ipDQ^=-p34xQcP%xpRE%Xx(JymaXf4ngPZUe51jB0DJ-IX?J0zmXBLUK{^k!OXX_?0Yyw#mlWVFba@CKjG%V_XSA2K5hn*DBOGH1$TZNze9jLuVI%?q z6QR1r0-rRLr=nG9LxcqA-2}SC;o9IG3JMBcJw2^@_ooVid#D-S`*%UH5eIHw6K2d@x_P$4LIl9`Hs8ob=#7cJN#mxkP4pIIPLK+39~xd7T_sXM{xa8>C`Ru&-k z`-JTN_Kj!371pB@sYuc|b_75a75*NmDj2rD$-GyrNp5_k%^3H$NKXJ@eK*kbdj|)t z!^3f)N&#OAI5~QFciKhm2UL`D%Rw|C7XWjcJ~@L;pQy5r22%Fhx6rxP5MWH#<^H;4 z2h&OCr_APj`-UzlDOq1%4+yaAz8vdv4vwAq7Ytbo(~*T3y|FXWtqz}q4rP*sI5iE8 zGVil}Pfq#~IWj}*{qMCWJ!WOYx=grXzc>tf)`tr~ulee<(ia#K!oyGNV7xvdRpgSd zDO#--h8y}PpRqzC<8LL$PU5h2Y}ZRsQDmSKo^Sm**xufTLFOL_4n=x(go5rRramC> zApt0@hNc3w6Ydbw3pw=tdm2v8hk*C#7j726RFE8Jar=g=WeYpnT3j^!^(WpU$ zT~mv#I4J#_?TWrJsTnsxJwGvPvIw~OS=!ep%{zVI*3mGD17FdB{ql;+9leT02?fE_ z6-n#eeaZC&Wbyyo8Nf1v#zEE6{A<;!{iS-AD_(mi&C({=6%5sTrr_ z!KSo|nkb!FaCVitba_SZy?fV?z4}EhiM%87G>Bj^FT3B8#Z*O1oJLqBh zP*dG!ZfHssm>B-7Sgtx^=Xd8ScnrQ)rs&fk-B1Wt!;3%ZET4DZ9W{sA`P{*|T=(L2 ziIT`y9cM^riTf(^XdUZ8bsN@a4r)xFV?Hl)>FO@PO-L-NC`p92P0Es%aehM(zl-OpwzG?b0}ulnO0 z=HI1`Z=_1OACGLkeYQ0nc8WSI>~rPiO|>Nc=-H@1BTG`x!xY==rlvbxEI# zkUu?&ud-;QIuAzHDV!O+Uww_MG4`(iPm$K!7Z-*mtVM(v_iT#XEW0J}gc}3v8*sMV zm};umnNZAxu%EZ((fr2RmLwzhm_D-NH7d$E^5%A)a$B~o$j7rjCd8-Y zntrNpXqcKhUW?pQJiBf9J^y>c%$Z+Ph;G)88nzb$wEQ@(QrB9u# zVkDfs;)ES#Ozv-47%mvdoV~Wxk@ky)V{0?(t)2)b#B2aB?Uz5O-;?B~O0q7E@;OVCG~h+B(A*&*lbG7^9gr+(`E)dDg zR!S{lguB{3K1kC*_uMP={CTT6QxAts_!4EsPH2OLX*|QfrJF>p&M@VIBOZ_1t&B|9 z=E0V7OGPz{r%|Esmw5`2p+2jXdBf$w2CN=(#h}8Hkp?|XGN1SHTXZvTHiQ?stws#x zH>Ku=8aXd7hlOdB?@o8TG;ph33$fm4&baoZ6WVDESPUE-O*50-CFLfs8oOR()_b`; z$@bv1iojugXneVr+myC5H5KUtpMZ*r_9VGViKP(hv{#nRhPhVN;Xhv_{^IMq8PX~Y z6BSp%d0LpWw~jA-5ZUHA(5_l4atDVm_Pr zqRy}W-O9JRV!p@-ruaGTWAeo!JItcN>o%UdH#WD)ZW=zNwoxdlI3Dz!KAG~gi$HSB zo-JD<6=0L3Eo-R%towV=I!)``AFE=2WXmx%Ew#I7=^|-=U6ZbfE10|3_G|gS^GOcf zhExxmfm^4+?qwbW{iL($Qz$hJzBL9u!9zbE+|b`htkz>QO#Ddt=;LKm#N;+?$XqN$ z(7x}uSVRnwlbQ21Q6*aB`Eq>%gOIoX!Oa;`P!;wA+3N^n3&~Ou?~s<>qdU$vmTgU& zR0|b;IH#Kt?op)uc^$cquP-U{OF2_g+p}3n27GS}KAOK0OK^VoKH)m*TCCEvX56HO zHit(!4#Sj0WQdHqF#cZ4QEl6;(4>6gCqvTiKF+smtv*QiPC137?}L(*r?=vWMlD$T zJB+786=&Tgj_Nmy;cW7Y_r9L!I>M83>u>MZVnr;DjeOmZKaujLYPxdx^zAL#nwLFx zA9)=gEB~WB2KW0ExBOpbzT|Xx^2jpRrf382k=$P6G}5Qd$p94-f_nNH$rTr!&`VDt zQMQIi>u60Bv#Wr2k+Ye4Rk$aQsdv-P*-e`$>N7i(94X(vPspI(UGtk0GQ!}dZCh2A zfBl5{^StU{fXmhoZ-vFtjW^-ph^qbV^43$lk-k1n70u`C202F02-($FzxBM593Hum zAdBCncb;x2f$mefZ(4p}cNOu5K!n=ERC!y^tZuIOZ`UgVkqvJjH`TTnW>t4(J0Co>(C#ORm2zB zav-i=oNYyaIG*$kC7_7Oo9+ZIn*uH+& zFqynn!s)oMhDISH^d%kj>abfeQ*@+~yXV8x&3o<&iTh2X`lwC(8CF~3Q+KeN8h`bg zDQBe053Oe?x?lX`+a~{LK6UGPie)s{1c|I4CM~6?Ma)mUglU7mK)d6$-(F00GxP&& zOL!`3cGGv8+q#JeuyZ$Ls%UAhy~B@oe(5w2T%LQm-zOQ|q z&Nca6h3ur?{N{$!W*X)k-RPIr8hhS68oFkS1m(_*q!5F_I`hgLxa((Lc8|6uqEue0 zmdQS5ylLL(Npj#m-|Qioxg}TKP~NFXm#0l6O<>CVoAOH`I+dJD9i}Nt1#PsR0mZ+b z%pT9~{615~7+>N%vA4z+9V7jze?G&7sm#Z7KV6?kG?Y_g`4;xSjg4;b{eg%PFkG9NnfWT2>n^zQ5ULnJOoTK=LlX!_kl)?i zYoo;!An3hhjB`BQH3FmH?^bM)o?4fU&feaNq&r~qF*Uu?A`TjP+)_nZ?jI~e4m@*D zhDZj;V+ctGOhsDd7D@$+(o}gOlV+Nlgy6Wdi`|~Gwe_n3@e;@aKA&?ggg+p6a1Z=I zKo`BRu=qAy`;hL{KTs%uAq)cs7^GbEd-o7#2$%#O{?~g6jH+>4_#*^E4rE7($AKB> zXyb*84W08;OaY~Da>c;J%CV3hJime zF)vX` z;wy|o;BJZs0)oOnH{CNFI5iHRb7y_eHCn`!2--4`{Z%%8tA8E?wOY9E0drdSkE8AB z$^BX|tI^QWeHpgu0^20K0EO=s&<=b1`&29}k>L9Qp$JGgV9gO~6tGvT;1K(dqcqQz z|J{ErrJa3!X6r+F?j9aWii&@dDboI+`c2?UjTY$1y4e0w_c*x0fG4GbxDeQ>kENB3 zcR1eE>Q1ZGWu<@o`0YIcs}y%pU1=!?DH&N|{ugCsWgysFY@Wqt zWGFm;-t3b4W=`UV4iXVeNH83;FsXkx1eqFh@vLLaZlSHUXD}WRhyFFPS^y@rxq+l1|{$ipBRG%YbNjf`+^ zfV02}N<(%+q;Ewt&#U}SLP#i;A?)LRI3Rf!17rSdC1a)7?}`b*kb~Q3=|=~7 z>+EcXeuHOBe0+OXEWI?4dV_A;wbHO}tokXbE2rdPb^vl_sD+-W6AZEt|bU;@R zf-vQ(or%k^xBe(aGdlI~TXl8f`nnb5UI);EyC)~P<>lowo|O}npvghDBScWglP1?K z?fm?FI8fN`?pk1Y5auU@ch0!|K9cO+=IQBa$AJ?dE+*@)QyC+d%Bri!I2f%#j7CW1 zz~OIg$-xWY{eXWj*Jc5M0lW!6OJ_j^D)7n9$>A_;Mlb`QSuVoYn!wR7E-!ykFJNG^ zch5Jgsb69>b`dS01sw_UHt*Xv(4&7Z^Mly}^yOD{*5$oxp5R6RY6&eMv#nNSG8>fx zOdgP=)y`|UNFdcp<_;p`kdSmkBAu-l6en_+hMT5GwaDGvt*^i7%j z-fLJ5l4hyNU8t3;Kz>wcSAm|4prDZu_daRggzT{UPqe$z1>T4#*%DbXKoNpSrJVN( zr74ji-WI!C$Kikk{j(0of{$pdBji>YX8qbzVWFG|^W&F~BwLY%&Fbeuu%2r%A87b+9?j;^U_vc$D z(yR%fD*kyrow22ivUhY;Q9EW;p*`|lD?qz&88XiZe*6>{w>Kb3@#gaM4MGwE5fNBA zc5-rZX3Y}+4sx%aQXg=DpkrZ~0mlv$@Kf_X(sYkyqR+<>Is}xTk6#R0aK#HSXlQ7F zhu(kU^A9-E?gX*qjhX$*V*rCMJ*hF#1G6 z4-RU{s6_~`iX<~I2o=}wV`vw~1Lc$m8NLU`z8}6}M=6kseL;^U(=Lv6KkIjOdtdg9zLtC zCL3Eq(+M2ksE)6Pu+36g-%Nv75(pIFqX$JeP(kEs$jZa}l7dq?2Rtu8p^d*<**`qq zp0+;H2Ui0;laTrSd(ZN-s+NF2mwt3TEexe6c!jvUMKm!|;8GanzN1Q3hl&&1WpdC5 zYs3Q8{I@d4c#RW1P`EF$l@T^uaHsvTsz?>`iU*^Aw=1YN2sweR@f4*wpD-gn-mC)K z+mF?{6$}A}j}f z4vvnm!5FR~SGC<@#ncOrJ|G@{0qdSS`0{P-?5zd7YWF2tO*p{x!;IO+LM8?nGxvMmvJO1x?%}4;vGD=;4~k@|66mS0>;S=H=xD7eo)( zZi%Pd#KnWuvL?ZwvN~DCh6EbIV_MqV&3{K?zs3U!7mDaewbEtIq);4K^DE0H14XYcC}vaB*<_RT)hIwn1}qbaaIOC&1^D5}d4o zPS_F<Z~Y=&wS3C}ozML3={1$2l@c!uM-ZAU=P zGZTPAldlfo@u@`+a&iHFLl2Y<+lA*WphOV}UY{;tE{o%6rvUPVGPDA*ihBF@K$*D$ z6n$^m@{aOyk)_mC#JI!3@ez>T%!fl-+`ANhmrtFRdwy8?6WFn_jQglo^Kp$tR@qWz$AOyF?VR`5fDS%;tk)J+mxLH|j9cikP2n=)`# zBkUqz$pQyU7l<#~+u%ciq06x*6`U)5=|U0XHeeBluzL=w75F)xZ^=aiI0Y>L0%`x? zfW*5+>-6A9N9g@3-AC$rHy7PEMcy}78+UuD`z|VCrD)3SXVamT0ED%K^46d%YiLLb z)=xwUtI@S1ieLd}_rXg4!sh0C5bEsgZu)1^B&F46@80;07FHCT_{@^nd_XG~wZM6d zb{DNB(M0fLVzmk30;$kbPP2A;d9D}l-z@)PJ{L@;{KZSuD;RLTTAlgS&L3kClgs%9 z8`q)O;659)>nf>9LllRv5W34Bm=H`rg%nkz2z5?nRqMvJQ6owyGx3=M z!m7RGi$@hsMFmKs7%&Sz(&SBxq{-)=2Rg3(r3DWj5jlCg9l~{)^QxLKd|DzXterkO z$!u&l?z0qSEZb{11`|_Ly*(#r+EA1Yxri+-ERe)bUN7H$;I>ou_r$Hiu5A2Uaq*pY z+?X#<9#hmQF58*i)7W3#*f@~g_otMmWZ%Pbe@5_Q4=OPNfI|aniFT-^_}V(xY*oR* z%S*WL=5de6O;h)Wo1E?Zm%+81SKZXzrRR7sy5`nzPegrr0x)^Ng8UtTQE-T9GUK10 zpZkY(8CMSGor*Q{e2KOX9A0w9AM(=>=4@PY@~F1gN-ORGKL*YKP&R8u7p`s7=xA$0+f{n_QN2Ko{u3TP zkGRt6_BI{lb7Eqmzce`x>Rr@pM*_hNeY7IWsTHyRc;Z$nY7PP-o93}1sE@5&W3c9%-3F>KeW2V?lPlZgo& ziy1281;GqoVH{{(RH}#v?^MG3uD%htWU=ylP&`zuW~V`90V|Wt>#+kv_<1=A z?-f(QGu0pdy>MHOB$T59-3LtwmIWhE|CoSOyeVAC($-e3?CN!0=vjE^#b&kdrO|d(O21?@^LbSw{&)*-@0{c{W;I5-scVf!;=#yC>)+%UO&cdVBJSiN{M6-+(dDv z-t^);*j1q1O{k#Jjn=~oc~PiML`H^K+Thxd>4+5vHKmJVnEJOM4`ZbpxbxyUvM|5Z%C3@^_J(M>|-Z8|p|Cm^H8>#16(ceWV zJPf6gCXAGb)+p1l(T(hX%U?V3kjnD1?X~bM!9opFPe#AQ7uk=2<2L%AMCcSOE3|ve zoD|sx8LO+t`rY1aV{D&p-ZiNmt^udiScP@M`OfyXUAp(aw80}|dG+s_Kewu8h3D*H zr|JQS=z48ckn%b-GhM*LOm-{n58jQwDi{!gCoLbEan4lPQ($6Z>a)FYcjpy3ABfJm zyy~LQ_*z#t$#j9UQpBF#kK6gm?R7vvfZ#z7r~ic(qmQL`bY>UK6sMzpR~i7v+kjSe zU(d@O(wBs2(PwIX-5`r;s==QFkIT&iCP8`_Pm{Lhoyru^*3xvn(FA-`w_DSN9>B=w z8X5^2=3MxQ3mFg(JQB}rYBIPw(P-R%1eFB=m1_XBdBA{&_!}D8V`1TRKv7OWF@hy^ zywOL1I5HR<#ToF+GRGwuFung@t-W_V*ZcoBu2PW(nGG|c5{f9% zj6~TX6qOK?P1$9ZBzq+xS=sBlKRci6_q%;>-|P0het-PV?Q_ogoZiN3Jjdg4Ur#9S z2jJR^PfWaJ z(bKcDDNR8IA9Kx{cQ!z@$r#8-Gc20-L)Ql@p~Uj?GHE;u3ng~$ z+T}9zEWG>%(^?_XT`41O`}z3P8miE^5ECI_k)hGi0a!S>MMb|RPSrZp=ZCFf!(Loc z61S1f*%PIS)WXj_Y&ZX?8@dcII*xTo4$Nlb7v+@D$VS{Z+Ap$a&z^a7#kD~mOQv7b z3~J!VKMnf=(UT4g47_>!cI`Ewt+5^}E}~X#^7zDPd05*8xR=X7s5S-(GnyI#g+eaA zvfRBI16i*5?2D$Mqi%CT&~v&ij~ETYt=ES}x8r)vLA#eN%_Gxf_&k>Ohy~g?wb;GN zmeNc#f#}F3A*Ce{9Aw@ZXsBGO&gkq$2VH^Y*5zmXUC)xoL20`8p(;{*tde}A-g!zQ z1)TzfZvZ+N6wseWlWAr+1+I=mbpH$lKCw{Gv6e|#XsiR4d(ucvV;m- zqg(9g(Q1@9KqG-{U>63AYwYZd>2m_@7reA_^XR;YkY*lj*X0fy1%>s{sH4Vyd$lz| zOI1N$UI>4UOy<0K)7aXoh<(Grz~ImHG#W2r?tq~H;uEN1ef|6<`DNBrA2GN$&YbrC zQC6hFG=Nw1H*0mnfGTdRtUpy`xa}RQQ-|!;tNa^o$u0-v)Mr6+Xq`XAgLiUPSvgQ# zd1bC!TTAQfD-Y&0-rbyYxf|BGrnG%YF)N*;o=Gdu{+g|ljIJ-kq+(NBTU+kR!if3R ztCT1(ciejKiJekZ)Fh`}H{bq+Ud5N@=88bEuIklAkJTWM71ntJOoq$w#Hnb(be z>Bn~a4S_|=e>BiLp15&i;c-ZnlKAoCuqO6kg0TWT5KfEFpp`C#kTTt>2BlR_dxj3b z_Eq^KESu#O6yUC0>lkI%@=p=g3bxea37@X~_`)V6A@NG|V!?K*cOOF!?BnPEjs@X6 zX3gW~w?D4O=5=*rH?6N6*57@N(;lqdPWNd8@p~EJ9o4upsWSeEWEliJl6t~+H zO*7%%L_|ccwOi%Hw~P>s0gvfvMn>?M`)q-=Yb@@2E1N_$MBS~0F2c|Y0p0l8KPJ%y zOFBX#dc8KXJxNI+KJt94cbh9S=x-DmE_W+wsO{Rk0m%PaQQKU|=upD~*Je9tcx0Z( zJZb7C50NHax#CNZOE0fA__!m?p|Ca9tY1@0i+BxSNM6X9C=qONO#4$nRcWUkaVCU2 zo&Us>4|R2#p45SDu@QwFewM)us;5KBVQ8qw>e&mAJ?y%42a;oh_R;tP|^Y6Nb*KaIVjPYT@R=FL?*%VpEu^ z=`n{rnRk8S?GAPsp)=3W-AqFp$lfFrX&K{rp)c_8#>x3&;8$t+!~0kMBum#>9{2C< zU@WY$ROR?R{AaHsibKD>((`BgLoL0Y-R?=@rHybOcng_++-OtykNoWhd@*AyN1fJp zPgTBZFAAwbZmmO2L7do)2G~U)QLvJ(t9zx zCEW7-Nuc-d9QQIYC9hZJKww8fXPS!TL8^EGOh>5gTwIP}Zvc=-K-Qt5$Qj{-2M?lF z2Dzem^5mUdYWk(ivh=*rf7o`&GaVXj0UYSMg7( zZ*-%jrAR%+_|$MM~v~`P6ks>VlYF=a9_9FZ>7JHE;>awj;JI*L_2<1LZa}2 z0@S^hVIq2BeYj|>)wFQV_^7UfjjUQ|>xyuKIvyGEOgPvhq=Uwjhy zpa$2_);{IoAsxb<0b3znEII*qS1zMQLmj^atixF2{>;p%=q$?6cBnJl1{_O(UVI(4 zXs!bXYPu^XccwJItgmob_+t)JCnCv&Do{0Ev9fvw_yyMpT4yO%;({0b>GS6wi)*^V zL>SSmk(+>z-r98E=dXd0^gd!ys7K5x3+n4t95O^$Qa*2&d_n1?r>B?eHh&oW2zoeR z_mX-wmC;OZHl-94L=$KcyeEInmzEa6@4s(z;NO_N}?s`uPazOJM5 zKC7b>(JuNNrVr^yCuw~yaBn~D3-;L*Ryb^B(>8ElU2V<0RN4*EVbUIah_PO3k(~+7 z7h@J!+AE_Hcl9lw=_%jm$SYX;hFppywFtm+Y;)c!$^D?d-sI#g7F_Y>prj6#k&8&+ z1Ei*Y?wq`(<$;)(m=%E#4l3DlOVZl-u8hc@R%gJj(LZKFb53@|my3iXHXO&jIQL5DrX=g(iUU#e0@@O!u*9|Lje8_vlnA*@RtM%vb@TAir zxB5_dO3rilynG5qA`m6h9qoXEy zo#VczIsS)lb+$TOfkX?dQgJ%MPYD+N3CR&1b4V|m*$KG!u*XtAdvggR6VoK3O^5|R zOAMrY`y5+Vix8u`mHL_vTB)*UrmgUc$`w5y%@p$P4;K9ffhe+=E{FX`#)^A;vG zTfpd{!yopfcwCv;HAbcCU`~e|Dms9-e@i_WPk1ls@Mk^f@=dx#4v`?=n2 z9@T*Chp?`_ZMQD7$OigQ4F}~so&ekr{n+GrxVXH*b-GXZ8(qk0pZ_6N@*%ur>C!L9 z;|&s1o3Q{(tS(J0q4bnQ)!YHT>YSz~+RBeP#nT6YGC6~vLAgMJY5<{SXVAn z`$vDe@_1qzSumC$_xOI>7EW?xj8Jfr4WSc!$b-0e=mFKcR6am2TIlS>Q?h zbHp&yFly%Xx*3O+fG1h0DjersEfzKHUFF8$Ndm~JbX&WkY zpgAa9=P2lwNL<4u15g9HgEl!62kp`lTnw zR#(P|Kc)NT>_m3@O_YDmx6<9GQ%IZf=8Xt>op3WGb(WO%_M{d3mP>D$8Zlap8oN2> ziUt^?uX`K`JgPc1?H7c`0VETn8}aR63q5N0V>4D63ZlLTQGKn&z=@8Txk}J|f@bDd zYguYe*BNEyEz)!Ex6P%LEF3^V>;K_mA4XWLtw(8}LW*W;-E zR=3E&3b^5x*W0(EV3oUj^T|JBZiB~(Pl5c6{RuTVd1=xhB1!YveCl{h zqW!5;8v(+=HXj@ss$g!uPw1Xunu9fRXlTMZ4Ws8Qz1{OS=BD;CgtRW zgBu`TUH%$0QYf5iF|l6j@M9}a853jKJe*kw8z!F3B$Pa;Se{B{v|oLDKr8E@=!p|2 z(9FJAHALJUlmB}7A2AuQE?yCd+F>)3Ur+#?s=_)av1Nog;hStla;Gzbp$O{^TA#Li z`L|Q^x(A`5x49Le2BC$*%gl_ouCDHtZq^1*XdkX4P!5E}{0CKcIaHNoMZ{CsQ2Bck zCda2Cdt@RhwT@wh(?LowstR-uSf>Kbw_ysGG)OO%Nu#^}M15}vtT)Pp|9I(9$9(zv z6=4>~zNYGLlpzh^EUynw;-}Mi=a}BCKnCpX?J0M0`)jT6{8Fye8KciOEB0^h%v#bOU;=Nb4t1@$X7HmAZaLFI&YEYj8^lOM1?nk~a+ zw&EY7xdqPC)ZcG28>+1?0;_r7dySwMBqt68WbX7=eRO-}S41YslEQ-v0J*+XH*9`D z(BlJ4Z{@Savy$jETvp}>f&85>bPmRDxAozNa!iIbbLp!Kf&@w7XYz0VeGe~IV*dNX zB{W39|EBOjRh>(BjFsP`f2YH>BOS>F0BMM;55rx%WPwpDB~;tc<@PBy7oRP=M4t!R z+O@{LMza{DyL{B?*Eg?bivg7OZV(V*f=kVRr!SsPwK2|WCF+0$cufs)VPw@gp|2ku zwHtaGaM+P*H`h0Q{w!x!biTH6O2{Bdp_m$u6k-OH{yl#rtsghs?EOoX)=dF;x901U ziMEygt!|1TWF_%eh0O}f21z7GemXk%k6!7^SHl1~_F|0n1 zH;;aR%^|wwiU|GAl$)%JdAa}a4{|m3NyVmgo(_;_rrf3&@>FZ@eEygRQd_`Ue@D=w zeL*_L;n`HZnDdpP2ec=Vb*55am~*q`{RJ-R*)fmH*BRBHl)H;&JocCIUvy66 zrA>e9h04NSyz|w_?b|(>5hsUgG6NF$rt&}SSk=`H%V-~kr!?DL>H&c3zc6^Z!FW>i znTnPrR#&6)>uF1juIYPeQ?>jJ)0yv`pxTr+^<~KZ#;Cb&*nnqx>3ArjAb`hSv9wHl z_3G+-OAh&8%k-}42BQj-wBG7Zr1w$Y!Um47c%GVyVCVa=tod2KUpwLStLSaA+OAST z{;hp-V$#4kv8}-YNj77U)Fkl2VdU$*@4rmFXk8~p=qTTtV&eo`{j#5zpNR%KlFG|j z_ujveVxr2Joj=FpP5bd*Y2iO3&nzu1nXlG1%U!v0C1O$xc=y)EY38g}C1e=+%+E$e zM=$H~*wFZV^GB^L^|WOq4;ecUo2>|(ISpC{pv-mHv^|(Fsncufc=wzDhN5KIzfn9! zrTpd^UtU_-aZZGKsHkj36N`b5+Mt4?eLZ~IK+A||67>RV5034A+ek4k^`))tBf3p= zwP>kLOiVy5PCI2V6@g|1L&3tv#shT^+H2|qzMHx@U%sY%e-}3>8oedCNf7}WKGXXs zyYZGbZ`+p0w+WZol%_3M8`K>qgDMqNaM@K+VR7*@rlxzKKf*rP^hYJ{e`^6E$>2g^ z1m!WNWBiL3_n_Fg@QS7#p?pN03)~uT5ZGZrX74W$yimr<>L5Td3_>+HcXf4J9~G2< z@b(i;Wag~mSWO~m6jA-ia zR&{a`L(9RLtuik+X}OjdprIl_fd%3Pfky)pUcRSkeKeG5J@> zVH0RMZ?^@|Op(bkyj@<2u6%vl#&DjoZtI*B%dE0*rAM)na7#FA$iT2gV+VYVjbCbI z&h(e#BBRmWjJ*LPBO}-w$<~C)^0H>jM!$uHzvvd~w+k0!&KVti@Z-m2;v7Xa30MdB z<-u&OjvKYD>z5W1dI!1)HT-aAW`Tk($~0`Q^N$L2Jz>PhxO~#p+x+!wpS`$Qc0h7j z@A!zkmv&o8-Q|8Lx^bavO=}WX0C6_>RO&?6{cr?dW}2;sz6*T|X}gh>)ge%xt%jQG zzyW{!YP48h8I-vwxKZ+%TU*CYIJA_W-g9;x{`M#9;D*`Awf&xCjgk$QgR~C^etk zq`CqV80Tz&rfsbWb!P=Xt;yJ|=mX&3fNMZ1WBY%AU!B9O#!`@i#f}uP^e@-)G%S*8 z8S~yVV+D?cw@mGe zgxmnaiT9|?;Pg$*&UTR7p51y?duc0X6@|Oa6*iAT0PbEH6Wf83ZQioQV8S6hDTxQA zyRCRkpWFa1woC$d0>-(|CV2+D0h#3pouaKOuUgAFgJ%wj6V}7*6az#khj1HA)M#UX zAzx;?8`OPAhZ0ae)S(s)2b>n|raQb!L_|jH^Qk?6A<(pG44pwU2efyb-@SEXU|=AgEFhaNC$9q~JbRWVlfPju z-@ooM&GVW2YTPx4(IS~5)8pTIvkDp}a~qpkr%y~pGQLGd^rz7d;pRwRp`+tlcR8`R zIQHvTEi7%x%a5Bao~5PTPf5!^v)qni1kZxsAfExi6F$J#p}EOU{)`&q%WO?%Ezov~ z0EcBLLen9WM5-jvBF}r>ThtzsU=noGQH_C4@fAoS z!i1ad2Fi%<-BWWI5sOElT*97$(m#o(_&|{W+XfE)_ee)oIzkuF!XU2bpD>uSs$D$DHfMoK)*Beo_7ygm7Z%yA8=Enl!}?)M?II_ zo}X-G;g|6IgFg3o>%_^9P69>!kX3)=IWjY9<@IIHr@aiZa%&I$et3^W3jY_#ipo5p zQ;t?*Fq<+nV#E`=1)mp%VKToQT~CW#rS`v@A{N9!6+1CZ@7rlzHDvF{{)~3~{D%`H z(0s74EwH8cnoq4B0|2jhm4B_w{}&i6P9^E59*urVbN!GVcI!`{)IYI)JHhpPrPsWp zXz!z-xDwN`?G{z<%v3r4qEeFLPY@ZC)f&KkZ{yRv?Y6T)^W~lTa?^9`<$f8+jk3)B z(nmIDjz`HrnEss6>WBxj2Ejk}+~bYC0L;^_?T%2;;=9EZ2SfF zs7gC+l9)j)3QnTnh1wT0a-y7Gm6nxN87wCWFTfhHG=5k5@#K7w>ujb;g&(y?0odZa ziOOA)Xz!17&XW2yZ}e^Z7msd_)#a<*#hs(BB`eeEQx$^lpGGYad_>xI!+lLA{aKWU zJ3HTDND@&QlPqr&S&D`K}$ zi$AQvhi+O)7hdvgn5_>((^>1NQ)(ir$WxR{~Cckti5OD%}+@6W;w`%s#b1{-b1L(=VG$s+hIY6xiS z(LKDzZ}|^p4~!8n2^)&6ot-fB>{GKXlQbpgzJ&fw*&P}lPKb|x&#pkFh}xBB-@bd0 zmXSWngPHdtpq&(IB6j4(qr);`BTfZ~l1 z>5TRH1V9>!l#NsJ@|1u%@x()7W9z|K^A-xCbtT1CTU(L=Tl~MeJIq?OuUUfj{ntS* zpdJ*#-*0Zz^YZX)XJo91m2~}=lsz+5?1m^%OWW1nz`98`ARLr$4 z#WP1J0HpwhfJ6|r&Iqn+16?ftZK|f zhSFoPRB1;}uDvHVwyAO#8yiIaRf!GvgNl3?J1GC@D5_|$YS7Ne(cvF%dVhYdPfyqo z7f52mx{G7*)M9VWv>vl5+f~JZM%oY?Urz<^gAYL=D#><+x;pBQ9_aCi@$=UvHkgY< zQA_jC&E`=*%5^;}#9D$gKkRx(E$f`I8Dg%wkKbZP9Nov1^kVTrE6RmK<08L*JEtbt zPp)}%X-7fleXSIQkf*xM1qE;SAhTbmRR{AHuDTd-`jXC@byWP5-l73dCPKcf zb4YHrgA8xL5v{==KM=5=-)O{p;oucUEp(fIYvJn-Z5!UtLZaY{+(xjl@Zou3CG}w5 zcM-kR6TVFwZW({Hgihyl2qCi3W)9mzDgP9U8#F)+3Y?;%OwiDvxkC~C(WdTm6&eg6D;t!J}^<|&QiF)9vkvQH+yAF@Yx3#}cV zH587hat&E&-+m)2>z6Oz9c8-hGk%SYX`i|h6cC`q(is~Z9M5*3SCC1B06Ej1z#(im zX3JDou~shBZv6OWSNzp$*J%AK)&@w%6bKk%k05mP`SStXinvY`_q@AL+%4nW`9e~} zfS*Lamjp?(UCzR}SrTJI(oB0wW=gjQ-SVbDBHQq**Ax&NLxDrm!7l2O{F>3h^06w{ z%BsT<2k3@1w}l`;5>4^4<{QtqPpHd#uRV7v)=SlTmGt)SC7G~S+fDmyFEUgGg^qPY zJ4g{51Qv8YKRH#^ZRj6{&#?;N9+uoCL^Svl6B14K-L(c}G87kUr zqmQhMvwGyxPE+VhlVqtXr~=Cgo`0pDOZ8-Bxw$N%`Ktv52FuHoPgbO>m} zrl-WHcGI*`k?H~)4KQo1f{9JVX4!h^CCI@C7Gj3)Js_w-VP=uQ(tY4JZUAUIw!^zx zDVLm`BS0!Z@D(5Lg-ECu2C5bG)g9PEFguQ+?QPfj@%=jtp**h$F-n(dceU;g%ju)1 zw0Cy=M>-E00k@?Y)+>JcDDc3 zv8cKw5zK9{Y7gMC{+Eo;igJ`YXRNG>H71S0RkeSsiNLE!%08?!;d!s+ZvQuS{-4vd zwzJ#loK}GdDy*HLQ&0zC7f|9)cl|%h-O>)%^lUHRywD$2P2#q6v-5Pe+oZT=yvi)8 zFJ_2rTBw=xx@ZTqfQ8}cHq`3GkKKP-n$YRc|7Tu40`c>OxDc>{dZ~`Gvm^H6cmrF8jwLM7 zxzagJ07o^zFCVD2YQPR3)bw(3t~MRKpI3(&Tu*87K&}0sTPbctFnK zBf^S+>5|-EW^$xyRthJ?NUzQ}fN3MSYTzla8-T!M@UemtJPpMUMzdYl zstIqK45jfJ2((ecLfegH*={tu1!q;%uFgxZ-i2M0BwGU|ItmaJtU}a69;B#PKJtKE zf`}kNdqLe?b7)|p&3N5<6?O+~$dCeTlU^QD&6}z}4n&qj1cQUHYmmI#X}u9_#+{iN z$KtsPK{R8V2e~L&;d(`ZGuMkm#5RS+l7R<3UdFm zr^|iOs(3OqT`BKA_RXfYHos2mI9ex9U}I77os&b*I$Q7ranmH zzl5A=kYpt~F7$OMvwgu)-M`Cr!xz_z=F|mmH<Wig@$f!g5!q*=>5Uakl1ccX|60-C7-eS@=MZKE@uzfIFY{SVn=!Ajs0 zuDpb4+~GD=XYb%Jfmst06LYg-6yi6u&CvE81A;9A+MvvVR(X(!Hi(3p@Y)cMeg|~hPyUQ>bu2q}7{ez=L?c9<4n6suyLSnV<}oGa zK?q`GKXVc>5t(fG;$G?1De3v?bdNgZW!I{@PtrpI!;0^jCwdGvk0o1_P|lE7&vnff z4~|`%f6T3j5S8^tv--q?V)>3Ic;dsC(DTb5LhXy{qMb_nw`|*nR8HZ+2Ib;=*j7&L z{oS7z_GU{pFKu$u2lCspFhNP@2g5);w9ZHS%|J-5Ava{S)W_UeJct&F0s{h5d-gTa z1BHalO~tKlma(x(-}w!DI}CR;q@G53-uE&k4d4r-1QtOGjbSC`lsv5hXa5vQ4z(&i zaDQDlQS4;vRj{8|wR+F}w`)OCi6N8ETHCV84 z9Fn(TErSBuKw#J1abEyuC$lE@-Oo9BZE??x7lTuT{;#5A*QGEGP@FiQIRyRaV6bP1p>iu<udlWOwS$PRBD1~i<59ZN9Y78{vV?9wh}MB2dbvKC-*Z`N*`j` z3@Z>l9o?bPi*D~<#m5_e?7k|x3-GwkTxkGoL^xXj()dX1NpbdB(>XG}H`S!Qa!)4I z|B;p*PX?35pb+VBJi3P!oeE@>6Aa%?WudT^I#m0JyD~^^%biaNrG0k)3QW-o5n|J} zGo~PVkLq~rld~LYK2NZkSY+c61UhzhRV;?UF)=j&k%3Z z$L0-CO=Cla?C&Ktx48IDFxNx&SQF>-9zmseqLKT-#D_h+yk=h?emeLUDnEVV#I5J` zdK{Vjsyp^Hefwuz?Zh=ZfMd-16__+jIwsgyB%amQdh6J~37w~jjIXatt& zTqh(!&vt6>o&#&*WGba^Vh*@UCVon6$jg7WDND?S7RC>TW-ouW|J3v2?XZnpLe*@c@X;fAC8dCcS-_l7PRMV|RlvzPo1nS_ z;fs9^H$`Md@Q3esP2+Wt$%}UJN&exFsF7!=bi*n<<=A(zvN{}ZGPASGVn4HIE(+>E z4#|veCLbZbPb-Gy+wh!gM}qx z@)om3B-IpW&0(z5=NlzBYBH;`4%cK#jIl+=TS#G%?xFYfPvEQ03}_m98Xvy~{I#B5 zTL5ne{`1+h+kZX98ul&Sho3D$-u@L?_GRs^78t5OF=#lJQ|32S|O1Df&#+pNLB z+=4{no4D}GkM}{I3R6pv=3nVM5Wctj4H7H*%Ls&J$B^gZD3L%817MxB~$8DA?ZUrRp^g*o$Yh&p7-!O+mpVuP85j zZgg1VuBH0EUQqk|Jl3aQUz*Ls5Ol9$J023wV%_HaMg zna$;UF!cbAR3jEYsrGK?e_5Ie6Y3Wa_cJIv6|V#QX$$Zhb(1nsZG`oRf=JWo3V-n6 zUH8gp_hgOH%A}t`Qbpc0b5n(32sfz}P~y}8P97_oWrMy90*Pzb_yBO#kU|j0RS^Ob zi2aoFTN|o6(>@R?O$opM;~#ui-n`5_wZfQE)fwGXuxJXl>VJN&a z&@e_$mr6o7X3S`@tpyq%BJm}{Rm2=jeKQSGj!Z%^7Y=(*y?J5a(VV29W#lrEO&Xdq z4v0PVZHhoQFlAB*$_=azjKQh}}V5E}pUsR5=hdwfkaww~BtO+A$ufvmC;b zXZHIYHlD1+;) z{$nXr{?-jD$Y+`Hzi3rqSR%y+j>)+PSt<~m%N^bSRX2-P4(lYo^$AF9aMN|-OfbfF z9{OsL4FiV{?)>xDuUK?8LY<)yHn4B-Q%?^EPGRUpxv&qvn->&52oxw!oN;n%hsp?7 zed>_uYt(zhD|X_A&TfibaUj?JjA7(jnU1FkC(ma9bxT(;tPl>DWKF4&PVKXpzBU*XwZXf ziVczQX=H+cfm;zsS|)r$q^NfPlgN(UUwS|y>Q#Dr5V8Ot#Ks~ZslJq1sv3x<(sNt% z1aM7^Lu-n1x(r9PIK%5nfd{4)~vstJz212*}~5R9-@OM zNUc9S_$Qt{ktnmA-A$ALkSg_I=_V)Ypg+LIf^P4o#OTmad|uuTPn3fWYby{V_2-5p zAT0Idt3kfL7a)D<>?RB)YED4Q^z^rI76qDaq&GkcO(fn>g+f;<+`q!DNZ6czMX^kW z9b&xA00#ozflCU~4~SHLxOOkH`6Zm6kQn~`J1IMx5mW#QjT1w++Tr2{!aH1o{?P1I zXZPOR^Q#RB>S&@d%s#IL(So1|Js}EEn4-*u$c4rehRK}4U?DMK z0~_=-c~GPy{)`)dJ3fGI@RXrpZ2K&*b@(!>8{B7vf0wMfr{(?F`Ct8$WRFapC%RRG z$NN}Z23{t8x>Be$28U-QbMg54Y%zJ_>sihNg8kq8h>mTeLHF`RCKeA@^eQA zDc=8{z>D{d7YYCwY_&5;E_iGD5Jw4_c`P{3*9fvAdf}Y*$;w02<+-XqLK&$$l_#u3 zc2-<}xGJrnUT$hbVW-S4t&JgTK;LOJxG48nzpL;8v6?wK<(y*9`XKho%Q1E0>mlkl zxj};#Vl!v<I(-(N7k8D(Y#@y)N`qfR_*At zZm`skx+vAW*8g6zQ;uBEE)UMBs(H3wgJH?djt}YGISeeldn0O-^X(7E?WO&z-(2Hx zjX6X|(`I6qhoRv2dA8?+S{E;Vf86Eq>}_J}CLhbdlAsUgO~z7lZv_=h_CW!{k^imM#A?04v%;ho**zF5hi-Hc_0PX><&S0?m$yMmn79N; zYj3ZC9*R(}8z&-_cyQRS3RHqnEfV+zOB)0>sJksn1|Z75gUusHk~K`g0Ow47_6Z&U z{10gwgMu-ae6IGs(!*H#%L#o>W$tQc4}K`f9PhXy5QESmrD;p?ULr17O^8-yeT zUS={}aqc42O*rQIoX0!tCLnv1^Pb?Y>Ci%hH3oWzAF-L-ddyy`hv>yX<~2uB!|tby z4HR4+G#r5N0zv-*b_G}`9qvfDGI{K)PJPg4kw)%#Xo!%Qduk~rDA)1hu9dTBM z%bdT_n5^5sh0Xc-16qmAUe2WX`M|GZIPyp@B zij5|(?4Uk>cuk~&go3)rh;`n1f%1M^mOE<{)N4!Q&q+a3$->HN@JG?@g`UGjWrcOz zI|JotefFwua2c}C|1MZkZgOa#*Xac;kN?)hzhNDqvG$y05()#$() z!p->j+vJ)PDXJmdC>x*yr9c(7`8VUsIqn-Av@$rBqt&q5O$Oq8=ORN~h+X%yrL7R} zXx2Oz54?Zh#KK||1|)m)cREU>!ES**6Fah|=3ZE9Q-}xz?5u8RUa4F>Ekw!Jz`}x# zL6v&!Jm;!fRkflRfPYN-W4QCB1LoV$AZW<94wco{KzD7qhI<<#8 z-FzpGXK`T;<*)g{H=uRL_`=1DPcL*`x^ZJC5KvmmH3@tPsi~uHM|>NHqaq??gjnQl zYy>arRyH=OX?E6K%xZ1y?A)Tx!0DGTAHOG3372<*mJ;`kfZpbdd2xS3MxX>3xIS^B zXOm`yv!14AKu{3#Y2U~KO&Aww9X~qpV?%-GoSRE*Lo3|M+V~5p1<^y;ZXbY z6uDZPyf5L|gNJ5eVWFTv19kn3*1FWuEWE`)~ig&1%R#Yt}zSVwwfm6 z%a>d`15J>Fh&S&g^W?K~>JBd%3M^c>seAHkg@uJftv^R;g-y%sw>5CW*R8kP*?aBV zlH8V0*I&HM|7B%yNy_xd*JstccJ0!i5&bnAn)P`s=VOCpTC28q$_kxkY}1fWlDYrM z34!nt(-!vR(3CsH>3+v(x%8rO2e!}_VH`$EPc1G36utJo;rY_b_^}-v@)ujtOyKC< zYbaa6cP0WC0~|~aZch1Oojd(<=Yo4(y%%jL{h&;kV}a!aRP5-9Zgr2{}Fidp)w(7)3dkFWsV)& zD0h)=gfCSoRX|qXeBhVU2C85Mi8X1psTGRhx4PSmcBCvDD(SBE5#&p~q}5%LpJvBk z5}Y=&H+3xQ%iD`}XBLVgGHbnF?>>T$RZrhBmeJIt@}`ibos)e@nV+^NMrBXv8QruK zmssxX=-NE~`B7rG|GpZl!gqlq@4Y$3*FQV5$AhcyUfdOzkM6tDjam2=mF3T!IC@V- zUOr4n#@Dxc`vxjHI^9wVfV5{>IOOFoQm^yz_2oDAq@@eqi+kb9^jRbK|NV=fUZpwn zX<-l5S}>3D>gQVyDF0wSfxtV#{6Z^_k;@WJbMCu;4c+E%uJniuc-dDwe)p)T^Co6F z`cUhwnlCaBnx7r<=b(CU$;Dt}mE0X;oHfSaa~j|Hc&ouFOXnDamR9!Op<=8S zG_N_UbM8^6b^42O3X14D!~qzNXAN%cHp=QkNEsTt>qN> zC4S$MEMl}!oW8)`_z(X4Txy`yBomv{!62#0ZyVn*{Lc?#f3)7xZz}bJ{mU-rL^Juz z2Cj25`Ey3s>9w7@c~cTp9+hIq6Ee9efBg7CL4m#tiwSZKYr2w+SxzT6g|)re?X)`l zEhLE5qp$tv#Er9EEn+K^ya5$vwhN1@Ckl7e-fFos@#miQM4Xws=N1Qtu=e@3#!KH6 z=;GSj%v{*zcYCSn0{R2g`L2ILhBj<=CbNBXeBMosVST(B)r9kIwje+{^zQ;Eskh!W zZMDKe4lR|=jHA|f5vvc<;sGJEb~hb2nBD4+P)?pm54OByX;i#EeT%{S58KkhtV+&B zA6*--*?TB?=h`3ZY)t8uYCB73x2jk#&haKT5)RpwDIZ@bEt8rQ2dg zRmWRSH8y{LFL>W=O(&aR!oKy=E+gD4HC8VW0&X7tako*K5)%x+nq%9tJCw> zc!V}noW6K?uG7>gwVd;__&YkP)Sc=Ky|athRL|US=Xof*KNM)3@RGYUblpfNMP9Cu zl+@NTA&5;LoaTno7xw|18vYZ)70RJ78zr+WU3o4#{Lik$a<7DlLDjvQ@bSi(g7FGg zC*$!pp@bLb-rN1|{Or}Q>PF+JUhbdVy_TYI{D(AMq{g+r=b;=7?fxGtGv`J>A(<FH7HN3~t3X8@JlqnGmf?rVf%*aDy7L=u;IEpgMa3JVNc+J866vvK) zmk(|0I#qM$}^s3Ag1GQ6KuD7WzT(bETe+y!;#fSGNJ# v9Qglj`;q^+y4NT++}HOx!)`CKgRO2{Y`EK*QAMFofxi?LROHj-E_nYhP@b?D diff --git a/desktop/filter-wheel.png b/desktop/filter-wheel.png index 4b1f8c4c0a6dc4e2218d1d52213752c2d86714ec..0918caf9afccbef0465bc1eb52145e8c5d922a22 100644 GIT binary patch literal 14262 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 literal 14383 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 z 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 C3d(-~ diff --git a/desktop/flat-wizard.png b/desktop/flat-wizard.png index 46e447b759a8936288e4e13773bc37b30945e0e1..cba1563d663e1d2695e30f7cd05f6553283bc126 100644 GIT binary patch literal 20931 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 F0sum>ofH57 literal 19634 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

$q{ERYy16McRSI#=`X=Z@Ieq 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 YOzvg>q89$|00xHCQ`skZV%nbn3we0TWdHyG diff --git a/desktop/framing.png b/desktop/framing.png index e0f96b0536c26190f23683b1317480ae903832f5..802a5fdb3f0dc10aaee087e15b83b0a21d79c546 100644 GIT binary patch literal 18502 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 H)baT*5vY0g diff --git a/desktop/guider.png b/desktop/guider.png index 294fa1e2e7eb0713ce2ca0066231b7a3c9bc0502..6c2bf982959acd6b2d0e1d85f93028369504ac23 100644 GIT binary patch literal 27628 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 diff --git a/desktop/home.png b/desktop/home.png index 87baddaf32c01f9db329ee82e71f5be36c149420..62ecbd8b8cb2386e66492435764e6b94126d9f5a 100644 GIT binary patch delta 4367 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 G*#7~%--N*c diff --git a/desktop/mount.png b/desktop/mount.png index 3e749a068eb5e7e52d43c74c7e33466cbb349d28..6ed630e7707f09fe7942d7b05fbc589cc3e7d148 100644 GIT binary patch literal 38784 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 NaNMFA9zW|+w#p3_~ literal 40405 zcmbTe1yok=_a}-dpcn{<2uO(12oi!cNT*0iBV8gP-H1wegLJn@cPUbWba!`m-F<%l znOSS@ojY^abuGVNVZFTXInUY8-k*B#k(Cj>i%E!yf`W2aTuev~1?5^W{2jQ74&Rxg z&BulR-T3rcT;V4CalWbN4PO&j3oBd8n;Tf$X#ghc zyznAQ^vuo3Ua*syXn!PQe!CF~f|-+r$hl_C9%yA}qTz`)OzP=n@6( zc2Yy>`?-`yHy#8t2wzM0#Nm(ScSl*N4HjY3XOFwM2#E`cz8kC`cAvX&X~v_>q?vd*2|x`I#(Q_}P8)Ptnw|r z@>g&C8vRJ;;|F~a7Rkb)h~TKn>42!$7Nz?sXaYvtLa4?@H5g3l?PLXa94T*jjjBd9 z1!$&bFqtVZjz1_Z#SyqqNE^aW7%qj=r@pc$b<@P1((}>eUFI<6t8;HbDoU5CIQb}D z?@DLiiA0&yu|E8?&%rp*~eaPjF(dw{rl%f%#$uH z0j;IxdxxWO6+Ceu6-K*~D7!v^ z?LzZG_4wu2%(ws6Bq+-8GPBq5Zxf0Bx#73-(`~U?S{g`{tt6*(GM9#6}M1CAJciTe$AU4GG?B=~-DksxvjDlP6i? zvPlz*#p2t4arK}HWWAng{t+}(lw@|pTv|=0&0o=(nG(fjrn_cv1J4$3(h2+F!g!c? zpW}>eP>{ZmbBA5M!Q`BLjIULqUizbu4*Bbio^y!vwLvEznTcdtb>j~Q*T%J1Grlf1RL zteH_iOUA!s+a%pjC-k)9DTBRKwV%!OY4O(@&=uAbzg|ojdZgPpe{B{glI$jBC9}n3 z^_?U1(yLdI-@4rw)9A1_AsaGqygtEPW~ARv=8z)DA~fARj#0rMf{)Xo!Ox&$}37ekBB z=hEt2+gC`KJqZMzWg`R{Bc9W%IX$eW*}KVE=Y7C#T1Ky5??7*T8p|=(Ynu7vHIHRO zW;)hAC3o`RclEK~1oL8LK4Nz!?6UD0M#_@!{t=Ahr=f3f;Tp z4VcPGG1H%f#3wQ?=?tOKuY?MQ4-(jQ{oI|4|3!WIbFxq-^fv#!N_6wjoK>F=^*Awj zql11*i7&mw@4bvVc@}W;{qoy0>&?w9ovzU3X|KfUZ)v0tf6^P@kVMzexx_bodpGva zhXH*0yb8&;Gcs52G@lLsz87$gW6I0zIrWRg{;bqcLhJz-L9>6Ri)@>X`q(*>Y1awM z+}zSmoA^>^hVX}4RVd(W)S4mX^5eKe17Zegv7 zRb;Dt4f#_4Z(^UD#am`yb&>a(|6o+qi=}|_SBtFCmkD^ESh+cN#F^{9H-A4-`*%Pj zbC$uglFi)`TE1Ak``{JVs~)}*mY&4FGx;Z`-aBjlTGOvuI7#2ntaEgZq#15kkWhR0 z%jc?;?LSvCyuniD^>fv+_Oe`6@J<}xh|gxWgY)GIs^;p3=H-oQp^j2BKYPeSZ%Ky_LkpuJQT_Dpy~`#@Za@=j0j!UjjD ziap9w$Wxe$c<94-7+>2f5=FmgqKt#GppD1CNC(qDR=8eY7k#TEbeAW zdB=<@`g?EfB{$uF+%K_a?NZRZeUsmpaFrD$^+jx@iDz>d5mv`(np-)|MLdBzd%yVr})>W-Pu4<(qYHF%Hd5H^2Ps>3(Iw0=qXYX&>MBlznjQRBzg5yDd&{ z(K3eCx=m?sZK{*PEL(OYEXY~)&~k zOn=3mtch3Ig!qZ$-mIExRJ3vYL1XSUY`k7ktSdNTd`I^sM)cm!%kvC+?d|xf4sr4c z4(~yUinQI|e`w<+_5WQqex|$mng{n*S|r6q$=9@)Ps+V&UNTN02Uq#aIb9148O5EB zX18wpS}*I^{rq6cxw>*9ZrV@0LTNKh}wO#M#_bwwBay z)eU{>{Uf0<^yn-s)%x?CTUYPYE6M)oC6(Q}NPjsp0W^XkZ_Bub+&AZ5&+GT+2ZV2< zRFoa)rrf#QFSp2p*`zkhHYw`f_XDj)wIp2lY)yBZSm6F;fe=;Ie-L5Me*=iB|NrqK z>q8|?O-+|bK>(gcWRHo7ul=_-efGaaou#KDuPfC9Rr5uXW&0ORHrw96kd>2rtrBB@ ztsE{by+cWfNWNu8)M}xx-EAK)GM2k z1->oS-#<90`c0S|bA-1zS zGKulhS8YR8Pa&+;=Kd=I>6kZMUtjMW<`j4_buo@M zJlPyJBzTIwPU>E&m_PYg+|rolPm1|&Pl%_wgmm_R&8;9dzY_kO>bV?F=9oM2GJg)% zhAN#;tkNB-?Y1&hiVZrWnLJI03M_YKg=OM7g~!UwZr#26`~3@r>hbXK2h1!iF|5YD z*Uf^+lzVG8Q9o?Wv~^`OvLq?4slFjW8B|ibV>A#OFRztR>t&c<_{QG4&Dq5zVEcXQ z&gk&4_dOGV3JlLivI%;t%$IXiOi79x<4vxN^+FnRCtLNoQ8u^lMhT0~;k60g-^{O? z693`GPaHKrqg?)P+lOlHMsmWyZ~a^^%Zb;`RR7y5wi)z1?Bd!&nVz2J*VcZLo}T`g z%hq4e>z?AtdXj)AH7)Jza!;y_V$B(|(Lb^(yRATVhNza6-JPA}H;KG?8r6p$kxu0s zHnz4cLqk!YmbzkU@6GgBvT@w17YVQ|E6QfxdcS}7^rSq?OYWANs7(SttXtz7z6&9B zb#;yWQS$SP3*)|wyM7O!HyN?+Hr;0Ym#@YylgRrwLpsjHW+H&_Wr}>J%uxit9&?Pa z2-`yv%^5?PkkG{LSf*|z335^rzNSSkFx{E_KD=VvLE<1*{wzi6jilhM(m0HJ_obj6l`b9-W$yr%q zjySGMUGZ51Cgdsae~N-K)5U%z&K z=LkERbi#74{IsM*@bcBOXJ0gH9iK#XoSZBrii?T8KR@0X9vfq^UY2z|S%@+zr(t9a zYHE5tn5QD|_mZ-|Twn0pUVE1rcfCz&xDYR=Yxwgh#-EPXSz;uwhi3_WB}9D z)HK26P^Ij{NK@0-+ePK&nPy{U`&(0qd3p2?8Pr>6n!TXdBO+bSk1csGk9A>bmM3e^ zcsi^W+VM~f{>BOik?^$s{YybZliki1&*zqacKhLbef_WSxwr8d=7+T0((Se;kNmwN zBJiL`(Q8zNWAU8_LvspENa#A6a_bHtVn5#bL%LNZEgJY3yKT^JrpZ8_-kQlsSQhnqO7JiH$VUEulYa@o~x^C zXABF*@t*i(YpYmGAaO^we5Nuy2B>&elYZ~$=tn5fkiB}0=Cd;sbfATxAbrzvu0_0~ z;L(Sj-gHUp)qbCn64o$%aY@PR?HDHKyB&->dwZ#HA9n~@y`iQ{+Sw>5D70A##oxT4 z;^ifwCMD|~8#_zbp??0Hf|ByvN?+#ng$rS~ffQjslkzr&*SI9oc;b57u@-e)Zjsw> z=Zw~rE?NgZ%Mz`=^mr5?l9HNwvYK0%6YmBigO+%M?_wVpMwRvYa0o15JQnY9QZJbw zd?f$Lu%O)`hO&x^ca#%HGt*u#k9QXOt<+OEm1;xZ}q7)l;Q&LfR@i-mIDk**Q^t?VYGQw&; z`PhAD?oVHtncBz68u9Xv6DUSQ1@ZlyDBIiHC`qunfBpG0RZKrQIeCYa_wm=)9#WKH zlWEGh!otGCBO}Rj*%sE;dHtiPKT1o}^YZT0Y6c}HlCoWKJ$iHpedVi~-5BM})|{uV z=&;oZRviIxdrnVs??T>VT+f%7ug#E>0FFw;_F+0bO7nHLg4OB%io|oDwV}dKGfikK zID>)2Tqwik=A>8WOMFFTWsB!0dwPb3NRMc0YJ$(CU}g@5xn{MuC=K6;W;Z8+3yb(z z>TxNO}C++=Z9nF&>OY~)y$y^eEj_8j(6r*%|`d@%FFZe{9> zyInfNX36pIj7><8`S5}M_CtEICr>>3`xRIT@6yoFz>mN9IR46ZefaGL+buUNJIWy2 ztF=94CTG^d^{nwChMT2-$(t+LOQKj6$1lPY6SbyR;Q2}(INQ#?)7%&#Qd_vFPwv}`XE!Zz707zjRXb@uhX)vfl4o#a-rAIo;)=22 z4RMsnG1r+I<$d}1K9JO2|Mf2V@qbX*+SG>y1wPJ;isXlROG?b4ytIZ#LX2r>xMVlo80y|* zlvYym0AQ6mLxJzDruaMP*FjzgkBEr*p7_=xPLRD|?&FqwR2Z&O={XTC!}EoI0c+=b zUeEvg$jP8n;IsP~HDO0ny~8M{5LO-Up2kn()#}LIiPKT)^UecV0c<>fVEC4?u}$skzodhw&IjFOH{GIp@k^qsnf2J{ei zr%L20RQeYEpdUrGTw}XUbQIV-rJpYR2(wt0^B9FJtj%)cl(6bt$hVhK;iN7o2n&`A zppZl#pfQou9glXK%kvWq&2I>li}{V?-QI@pG<6w~v0|0dR#)<{yN>jn<1J;Tza7#-H-92t7LW}_Dq z9Ok=w14cb5Xn=Z<5rj;bg(-GIy4obs!lBfmwQH^I?M-cMw*V%e(N*_QpNc_oo3!fO zN~_|^m0}{a4xevPiqVATE2nZ0gB>8^DU;c3#5%jQL}in=w6HLCMm2|Ev6j0Qgsq`$jJKlJJa@6?P=l{CF`!?R42&BPzHLnVG-lv^%@F zqSA;i@hP-jZ$INlzxUWXAmA=wt@X*;>N>w?a9{t5jc%uXj%XpqxN01(KqoK$nO^dO z*i7yQ+K$(Y$OzRBJjkY-$Tjllm-r96B+O(sgvIbVq z2(inbfb1O}90GYmX z*#!dTeWBfS18}*xxVZMk>B0Qc5^&4Tus6IuO1b)d8PdSJ=C-!Fffu}`jaVNm4}!U` z6k~gN?vUVi=>V8fZbJ=oz~m*&Ni0HIzZ`~5_)~K@YKk(yxM&4Od|50Qg$R1Ng2xfKhB* zTwUhPz`#JM=oi=Q?CiP{`MSH}xuj)?Uc5&E`uCmVQ^x2QywfPRX`8nS9k!a`+05PU${A z{vT)u*ZM5C#>yF0^CPb=kLm&E-g0wu3+p>vA3^4lY<5oIt~1}5#@J@*Wv$cEZjUJG zTV-YH4SBe9dnc#4)m2|VKfjTV%j#-1XY!{{PtNzc-%>x|K3gyD-G?8LI6GhHh$3Jy zxZ_xx0RIY$jco^9WLi^DpXz8Fq*x>Uf{JRX`*Z68^poAaZa%27I9hYc{lh~L074=o z4Xk2-Sm`3QxVR{%uI>+AZDJz6w^vR_SGUq~4xNOA1hDVS>}(UT2Bg2zMqomZr0x4} z>cY;>PUTuqb#kT48JpYr4h9f5GCO8+_qWXeiJH38?aX#K(_&YdVYDf1%swHMff)0BW!GJL|%g4 z{f?90!QMVNCPrn0xv#J9+VXfM3+z1StBWIh2M6(ZO}xU6e51|ChzRjLRzm3rV_2os z9x($0O2FQF`uZxo_YbJ~_(-|9xRPpkCtc6XP@n`n0|KPe4NeDg-_f}Yjf@ba_89#< zcw4j%qmA*(!!FZ&Go4v3yv$DKj znhv5YJl_PB;H|p4R3fj-EBo#D7Y}!_BNG#`&PTjXJ6XD4z_&1O-$ocRbBxsJ(%#-4 zhs~-&ZA9slB4yOdfAQ!}?^(~a*q5d)-q=rvf9l`n;NSpU11kbuHeQD6Ip_ppd$jky*vZh* z&|HH@JaKSp`&-~xfP0ykWO3YMc6yqulopVha*+<2>?Gqr*4wACtj6RngHuylCfE2E zsRUuLHGG48O0#}yuH3fIkQhOCv`PLz(E6;X4a@#jM64jvVeLp~S**`gCMRZSEoH;DS)mpFf#_%!#C8 z1ByuE9t6oty~?^x?}X4wL;3V0v!8lX&Wgr;k<<{N=+Hi-0gXU=h8^|O<;<4%Y@LPe z!!SQ|Ra)YTPoF-eV9GqFM-~A?LA1I8!=_++^xhUF9T{t zQlC&$-_+95VoBej4;WR?|U$A6zPs?-*RzsHjA9{HCYh zUtVmv^?|Ax%Vs8FYs;pjq$HKV9Ru%gIo)tAFF&8=`SX_qbFke&YylwJJTMReUxf8F z?ApAIvK%rg{r<`lwNhOsQzrf^YY?suV4NRPiXnNDzeHY{F}DlT-h_E6;{=PNu$vm#><%uDs%y%)6;NsZKP~XG4`xCIBL)!t40f6$VE|!3(*(0|Q(B{=Mtun2Nr9C`LunAsO;_Soz-(V8C2!X6<(gymB1res96j8MnoAN87F7#9}*{j0D$Q& z9cHa2glDlWwV=6`e4L0~UN!#I`x0*)F%!qVb{AUKDZPhSuMe1Rkee*n+O7E18@s ziE(j6-8@H+J0hQ_mh|ry3V)=wbbB!H?2vpja*r(k1pP#$iPY^p1m@{bTh+W32lNXB z4ay7-{rk@A65A3$^zO_(F&zy9qZl+nRdEsk=9vPe{FnEB#aLY2w=CpKL6hUVw0(}m zO=f1E{4WL7^saBru-~9a39zuRpz(t`Sh}xZ1oig_ZW)HZE?nRZG_)R|ShVHcj`sF1 zoDM&%w9P|vEHCeVR_w)cKA)%t;Rkpv|1@X8si2WtTedwt@t+2N$trjrB`XC3n24SQ?PDZh2 zwfa?1R|fNmDl04VR7-uS5}iYV1r-4fLk7BfAua$26&fmBf}c({My%{Ux(pTRaphGl1`l&0ef1+==L*U1f| zqp={zit$auKu#K3OJ4vEj*Ejs&U2zRak|OsdTw8LvPjh1*B8U_i86{&3%%V-l!k$! z%=*{_v^ge|erlv=Nk~XU^Ek1=CQSw4(-uY*_WgS&+-sVr5&~9KDlC$%f1+(e_k?n_ z1Z*w}^X|!$C+ovSb9Ehq0J2oeKT7N9kjq}y$U{TI{F>5h!D{!h1BSFcV3wX0>zb3r zI3Xb+1=_ymmc~YpCxYL$FOH_J+ZAHTGm!wE1nIN)m4j@;F|ZB8?u7qlX2F9+?1Vpm zykM&QwBOMIP~8rFw8HU#7M?LTF{kx3JjLY$M*uLlz>9+S{L|d*c?*|vyAgvFMbQnz z&SbQN2JA6pz#)PjwDC`%Y#VD87P{1R;UFq=T4wvxr%!L)zI{Cj6yWuV>T-!)3mY5# z#m*SO7Df6Upp#d)$H&LF#UB=T^ESiv!x~8T0U@y8ZKHadJ0M4w0vy`j-hTURt6tKu z)WgGLeqlk}lWDSTY>WuV812%jMppjVD(|uzYC9TlTL)!G#r|PwG{s2gYk!fjVH4CMVqF(1513N=} zEpL0j@6D&>e@{RUcN#jFXlrTt2G&&)_X@_18=wro78my^sXLutURE}q@(Bw116Tpj z1+3bCWa(!?&l=T}uFd{?0U z9M``uBxj{tiAEW(upz5#D23)EuD|>4eW&+6kF#O{h zJ3IT~))W@MfkB4#Pk9A}FW}cgj749cBH-8e=x8yZ8a}?hDIm^*_5;*2ZRh;*l&eBs zT>KX1SJbbs->%T?!sxQ*)TpJ{!!d+L z1HwbcBIyKYG=|g0Bw~wi=NB%bC@W^bd+8b){()gmO+$mc7jW{u`LH*Lnha7Q0k{3O zU+SK$3Bs9g3w7`2X?~5zk7WmSr9`c zh~zIIMsZ+f-MnY?_zdd7-_{Gvp|Gr1deI^zgaP%@(9u2R=B{a9qFsf2DjNkoy+2If z)BzLlsF`81>Hu{EBU9Xk4zIEkp&lUsfT}>2d4PwfsIu9drSt0@ScJf&JOTm& ztaYwiT%6h@)f^}lbr-_egy*1ZZr%+O(%au(D0jdLguY3~wdV;Aiv%F7wFdEB-sG2) z!x2e+%Q>w;-wjO*BrC^*)!#7u$O9B@`GAi-;e!7V5K$f zXV9RH(rP?pU(l?-ZfQ_3Twqw@pc_z4uW zu%WEmw{MHf$YembfC0^{^Xq2PxtuD@8c>gkf_M%e!HTh0J4gOB?xTcQ#4liTubd9^ z_H(A8^T*`{VFc{BU%L*;g$Q(>aR9$4u>C-Z>VL47%fVp73IZgoE^y*^j3LLf{#l^bwtszI;0x-cS z*xBEil>TaI>CIDNK@uA#CbW5MWn@78lUibks9!yLrb@j(Ua6>g84}b(OlfX^mqC^=c&X`&WiV!iEE$A@f`iJ}b z^HA~gz-m8?l?6mTSL2l(F;TNQ*db%Ocjr!;u-`+|`;YXm_5CdB>hkgypor?8s?L{b|Y8&26(uSsCs$hU>8lr(kPui{$Ow{?p`qaeOyOE8tgPmTtw??b zxE`{Z92^|-G;0anfg!RO_datx+LVTdWIC9a44j=Zs>p6j6VZDUTu+S=08njfmLFaU z%Qs$SlPecC4p;%fYfv@VczD9_To9lSh**u#q5L0{-zFz<9aDKA?%wFHIl9uJ}(k{<*Zch=Yy&i)tD z>^CcymO=aktGe`%>*n3N3-GhQe*Z3VI{NsZM^?51raxeaH_$qPj=-mTnN0Wi4&f+d z74Z7{`bxDNyIOCIMgXz`O8v-q6bUbYfEoQl8x^<~OaO`e9l+m!1{;XXYXppqv#q0| zX_#aDf#?*yZ$t$oZ!}i++`R6DQeR)+?m|-o$CHLv?^lxRf#9#&OYg=9YhI6}-hz*r z#s2?x)Zu@!9l&k%+8{aq*8csK?=ash?4wipzE{2#YTwdljY4kp-fwwga_-3U?R4PaCZ&V`uHteX-otQ*41(Xp zsL~Y4VFjPbPJ!&0rJKT>!7Z#aUM(V$Zx;eH>+9$DlrOIlgE5?+|J01G5} zXl>{4&;S7He|h(eE?wV;5#bKeq5yC^Mee-yvHM#6+Xd`Xn3f@-q2$tQ;P~NEO1%dt z4nrI?Fhq}CnW}e7cSA<>M-`U`aMc69C)?Y70bgSi5=ubY0W@PW(8dPaEtkxVCuM!X^xw-L6%gf&7IO_H5xt(!fzgn=g}i-{c!;3J+)4DhzSIQAO4**)sH!?yOo>1&nGGO@{?Mz-T-B`_H(!zb_>?1%jH0*uczSPy=BF zMKR)@y(b1%hSSj|h8HbW72t+q3JMP(__zcp zY8sdW88AIaKc&c0gTqM;#yh`*q|?sqZwQPjjQ)`Z?uKYRaKDh^;cz~-NY@SX^FxGe zkh0?72LadMLmM6jSOHxJ*+0bV3bfH z;HxA<7b~p~!AgeEClbvB+6h(80R}*C4Q#ZK;NS+(Kq{;jd|7K}y^3^xV}^AAS!PGM z0)ZE51sy>F;BD=}gT*Bx>HvgqVj4!6_Wk=~fKWm(Cy~q@$OaJ%q?nP~rlxcnmk^Hq z&S~QdGz@TL8%&4cDjPb8`-Q1eAhtFr-_VfpzP@e*SF!EuZG4zb&*6d)BLoE^i;*yc z$A@?k&;^Kr)MXzV@bc@_9t94zYb75>LZKv)>jWnY@vKlJ(nEoL+$SJFvP&i`>IT^Z zGS>3oOT+RaAya+_cklvU1ICA!{TdmG0m@R=egtkoh0WT-lamuD+tAop-^TY4m6{lG zEG{kvMkr7?bY;eP4W0rLuYi#o(y@T-XL#Nisu^KnxUjY$ECt8K-454ExpwpRZ9#@8 zd0E-*27R}12n7P~Kx98-P~AWndJ19Ojwr?Q*~B07i7H`WO^C3OQ$OBcVW|sQ zT!_3igE$Zzd>``4;4y&I4u$|2`S#P%%CWz3S}!Kiupc}S0@)j)Rw(UUA%M>zhlVgA zkWsW>JTkHVtC86U7Ts$Ax@iYd5fR#e%xw}NcKPap83>5gavFx=fe?0 z=-j=K1x0{9MKEw20$+%Pepe@^n_7iJo1wQSQ8}je*XL^()zkC# zw5#@T2e=Ooh#Y_~PwKDE_!{<$Xdu`OAPXrHXn@EWsQ`5UnyHJ%Ub~Mf&pZ^y(Jc8m zt(W-$rbC6H{&yK2gbz^g5g362=qbmRfH@|wcM9}m9f0bf8i@(igY^lHcqr#rQUg#c zfP58;wAD-v!;=Tslyc&$6;Lf;t3DD@MMyP+feyZQct(0!8n}dQKu>_IUI*c5k@V`= z%X(-MNGTFO0l_%q$B+B&5Q+>W;c16dENF3v!X+}M@D5o?@G!^^{@nb*VxycC<*>EU~*KkoLd|ABaF0r5l^cTZ)fOtq57FQ>eI z-u(Z>O4Q$&|0h`Wzdn3wbNH-EE?W<@D|jTqiHY5iQ~;Z~jI|#8Kg9Y0f(a>g0G1Sx z--j|rGPU4RZv*^*ROfddr%2Ggz;$l=)XjrP5(NyWfW(0e5HH<3vf z9e5G{$tPKOhM*W_7O-avM6;O@QptRuhLI1~vjgO98?q6hF)_Eo`FIjQa#T?vK$0uK z(|e7|ARPG~W+0Ze-`bRTd?|mG&6>!^j|{-LXJKq6Y1O;-awBQ1*RMfzh9L_E483ZJ zH|T0Ig!mZ{1i56S>|3A2z(Sn|Z;O#jWe~Fm67iZz;07!NH7P*II~~gn6#sQjCa-ysiB=PhUyi{`oJw3>)gIo)y0U`8Su%Kb0L44^x zDQQqt)Vp;3k>O#Gia*1{2Y`43bmO=P_{00LLVuY_aIcHZ^eB$FLG}g#K zfN{i*h>F4o!Lg<*IbnW6(i(6Mk(ju6wyi4~3>6rPup|X>cO7nkp8;lY z3k3d`VV;9T8nw7EKW_ofpK&xnvcak|>m(-L(BnV;1kE6oOS`rJwj<$j{PAIgIc)FB z(ZF^|trEO^VF?LeR^C1>5SA>DN2dax{`V}vwswhXzO`9pmHstQ);*x>1N(&oB0rCb z9-DRm?6t77TY`PAxOibhwB;`jyVQM`={Ja*Ta^i1^|!a7_I3*0F8pAr0USxStQAgq z>?*aucsT*~pW|GYJSgzBy7nalAKz77WDgv(@nTS6yMc(kji1pT^Ey|wyB51$xlTIo z{iVG~XsR7hK@_FlnDEllmmr9N@d-1wDV#>$eYdy_f`+@$T693cgh8iVY7)^uex3_d z6YL?!T1{1huT%;4Ded<=@zC!)a#zZYaP?NrFY9fMW@BTEh(rqpT_5n3$F8T9@a+|C zd=e6d3%8~(w-i^CnzVF#L5Fa5c1EBn085C(TEdy7u%XGTQw{3+ejBi004HtL?8!u6 zzmawUPjqMeQ+JWu)ulwCTmHY`)ubvHiF7$Yeq_?`at9(fKa{GPmG0qTqQ!IC=s=Ro z<_B6(BwQ=}fvq7D;Q+}gnA6Ol96M60G%FcK{eY2?)-?i0vam@>-+=4{c~M>HMsTw1 zQNB_~W}>U_>*V+C;WU6$E(*3D1VUm{quMqi^47vo;>7qkga!(B;~N_IRZEQCW)Fl# zMZp>0M#z>zo&oF$C@RQaDH0e|!w*5oi_dy!e9J4F6Qz$y1<*8LBt7^ru8440@#?t@tWa6k&>;d{Z~kw$q%rmQA7zaQWh{ld9T>sv&5 z$YAutgYb6Ju&mIvbjgsiEz+9K?g(Lh6<=+pvYa19SjVd~1hAU$|d+{O|W*1_`Ykdj0o7oQx6@cbXyuNH{v8Q1=68bYC=0z2>l&JILN59#iD%t(j}7=hVniGZY} zA3&k&+Yocy-CvPc%2!p)Qoqm_7FbG3Vsjuui^x~XCwwXz_>7*Oerk<3B4PwiBLJNR z*zOB^9aZc|W%BnYvZ#&-*GvH2#~{%msuuX^y=t52cL{&-U7ZW+>814^1drtcld>~* zNwaH5Qobr~;C546M4#&;7X}9(eruE-`KtC-)~_*^(@0{T9rE@Zu09#9-G7j=AzYpN z=I!KzoI&eN$iFsuiQYis4gwV9?#OS~pvAt1{9+DSpPTZpXKvQ#6x z1KD?6ITY>)$%&1nEx4x;4!Wp`NzUuh{c?&?O&fTVpd2&C(OM(l-S~GRW|cqS;KVOe zGD#n!vtGDpYj0adu3ysD|C_7yJ@_g|NJzN6x|))mt@7QA#u8+{8vC8sW-J8#`4wZe zd2X%(_a#J%4)|GFSzo+(VZZemW4})P#*Rd-`(Hv+jSURgu==mu z13|R!C5?tNPD~*8K@8shc>4(o5*Gps83Kf$DIShqu)&!kpu@B^?A2h3A+8oY0wh-p zLtRoYq2k*&ba;TjUB5R`XH9?z-U)+h1%@OPOk;0vC@2#%^Yh__JX1<4=jWfv9`Nz- z@FYF*`k`ruMjzDy2eOb(3w5?SR2Zbr;5Nsr&rAU1ft-?3WaSj{9cAWI1!eDt0q;TN ztT9J1H@tw+uQr!`!1!Qw0Q@eN55q4I6CYGb+4oL$VCye%iR$m{-To|V<$FLPCZMCE z^W(>lhF`yKu&}TI?McJ%g!)__DkOztVsp?LAHTA2hZMnmY-|Bw(IFurtRKgt(u75WfP?dbjcswt?dmshevo86;5OgFPAkx?ZHHw`;{+v7 zUi%DKCXy+GC4%0wfE03%f=Sf(?^0k$f=&1v;tN2%dVraN4)Yr142Z`7j`9EsplxK- z3IovF$L9%bY;YQ*Y!A=`s0Ag=?sL)8-vJ*L`7IT$7o4M`qbSyEg8_glU_%N6bjp@X z$3(<1U^EJ}TA*e_pye+t93be{ayQ^s5Hy4J!~h3cP*fDd9g;2gNVvU$%{Srg=Z^6? z9r^?0+uNRLKBBar@9FJ@(?Xx&Nf@g@njc|RZkH!iM_W^nDpMRi1oWa_?^;`u11Gf! z(#uH1v<)tomzNO(4mnQ;eaZ`Z4@g%xlH}4Q6bjU1;8H?ELOfz(2!e?$kmm0aPWLVl z5-Ok{!~KS!N+&=ntrxu8l9CcI)1c^4?-LVCD=9q$5z5TWY(-OCV65IJB&0{p?1)o1 zen26*ED_ixY!EqjYbz_%hVk*o?jF}MQj(J!Vd>JDhYGbAe|%g$K2I0!q{RJ_@>UY1 zE$&1AL!K}*-W@P*XOCaZ6p$z0az=`-<9t2k>B`wXw%KEFn zpLOaK!WZ8`_QNJ7{tKLBN|{Q3Y-xTzDKpb6A%O(Qq4~uLCY`xb7_E30=GY;a%Fp1? z9QXuZ7aWcRprU0U77Ksm3b$Pp!)@_z2uKSmn>&>zzp?vb?rdUnbLDRu3UyN=iyNT2~~S_(t)o znW$nrWEbXdlc2x=Xr7uP1wan&d>$)xeW8M}oxpv5rCLWD4t^b-ly)wMC@-e=*eI{S z$!|tZ2h<;`Z#Q|-Z=uR_-=c)(nI-hUZKacIC`hEb2e2UyPX<640PJ)v9Qd6wg#g(EI61SiV)Oq{Mgu7Zs%iw;-u+VHZP5PzaR&)kyD2Maun$x zUMC5v9rPWTEK=G|E-o%rwj1pbg-iZXqx0MM4gvEG_}=DZZ3j>!5Nn{FzzYC--@ZEE z!-6G};6qLaz~j?{&x35(H#psSt+Ko95di@coF<34`3vMy`}5->&e#GmU?JU*L8<`% z8Ftq>L`xtI&@Tw)aR zdKyu9fImW@=cyJCf}+7+d&H!31DYl1;n9Gy-jbIyEx!bC9STXby|xroLSqdu z?jXO0f~e2P5z)IuY!7N@e3v0Ps*tZ50fbK;3I#|cqz9gXwho;Hl+$YnF^71!JoVb@ zs)VyMH^^oU4Gr#aDiwZ#h#8nk7!7cBV05{cUD@E#X3vW2Qd6Bv*c2 z;=@fkkhkH)E>^2_Tu9WL$-cfYIBd-N@?}q7-&07uf(ytD#5el`+!|iNM>>V91|a$N zJF{p$K0Z+0vdOQ}zRrP$yx`PMoMbYP1L7mfeW3p!j$J#3e988~f#OXPuuj7hq@vpN zkeoTdJ-Q2LXiU$@$WYt?qsx_=3^0dma&vR5pZdeUXCW~P7Us3eYdf$6XpsXD8BS1r zBQ5>=&mYm6E5ZAkf}&jPxgL>`yLSk;;&9eH=Fza+fzC&}U0u98bpnA4&W931@1f%6 zeth@tT^jbVuV2xCQo@Mzf&xc%ho+sQkp0=i;~Eqi$OzhcdcUCedW@gTwzRZ><2Ik` z1tQ}9(`Ya`larrRRaHGABHsh{ZmrjUxD5;pps2A4V#drO zIu?+nfsCIVX$y!<8L(AgXTV59BpV>I@CB_>o*1Zgk&%%K*>YbwJ}shDISt?JXP8VQa!^gM9i^qEfptJT7#@D_z>@7WM*b2zWvn`sB1tXD9DM9?dfaqMi4U4)z$R`!~>Ql70C5~_N=X~Aqa8@ zXk9a`JhXV|WfZl4|FyOG05EfAUz3)S3cqI;fdpjX1Y&xONxAt{3;YJOfNN>sQI#0= zprXLkzXQLMg6F<~eLzx&sQm+i7Pp}{5&tQ{t|r=d1&u+2@=r%hD92fkwFO( zF>HV@&^(aU8S2u>`5*kmA0XC(8Ywn5$*PI zdA`s0`?b#LQQwCgJvny=DLZ)c^~{eshs_Cw<-%@`hj)9tE&qUv36PQUAigR<_%u42 zk(PXHd&6?y(!I%GdwORnV&3H6^CJyPFKw*7D;?kb1nf4f9;dzu67+52i6|F1D0*vS z>EF{H*;xFqHm2`>Vj@+Y$#4)J+gHaw)b?7B*)I$-;GwcT^(j~TpsAMFHnDHt`+40* zI?a;~90w$Pw4f7r4pLH~7pdKXAh)eO&aX^5?U_HOiv zQd$H@%sC#ao?bEahePT{=YP+hJ=Cnip#DCP0(tfs3`bcn)GmVSfN+`n02iZaQqH@y zT#k*#uS-Jx{r4FZG06oQI%1-El=Ya{75_gvkN;_i+xj2riUD2}lCJpw*RQG^M)d`4 zx|N+>2^j&9Nch!j#Hl|x1V@Nc%*@Q7I3lwH1??+5n*`^kK<)`S3}bi@hkI*b73(3VN78B+35&6^wx;@okqA?Xp|SCrx9DFX^|}Xw2$D2Vrm?S~ z8h~NOINp;yf5vhY{{gg5K&Wou+gAwTcgImh4UP7C(0T;aK_7Vybp)uKoWbp%uODMW z7q4RTlZ}ra{x;s6p`qa?xVbXSTFb#%Y6Fa?;BdY5>#Yp$)yF9BboNn2hB_;DatMG(|e9TO5FddQ=C1y`HjJbt_hj~v>_WU#w1Dx(m0 zLhjs>XYWtL#7()|^>q_yaQRj0LTVxng@~4HGuS@$ffiNgKyHwFw^JH2zNp#mxRdwM zA>TAK^h!+RWnmfD>hE)2>MHNWByIsO@?LOvY4Q8=_C59S;nFJ*vmI-B9;gTl#C?!r zR5<@=PQ#jwx)hCZS(QREhlqdzESTy7s>3IU;|cwY5f1nq6I!6dvA)x~M5hfFWErs~ zSQ8dV95QMJt`UM+MJLCq6X3D2cw)0l@65~$vMr?a^#0}3vXAczQSdD7|W4Y zHhhj*pZ`*ULDH-RxNTYN9Y(9krz|JEAzJoFcZn7&MAEgufqR*10o*g>UW|l1DX_*8 zy#}2Irm>irVB@7IAprq($6mLt4-pX$0yUGL>eGxz0K&w;dJgSQqygZU6Xa~hXu9q# zrVEPUojX%#=}uJ5)cVbhhqucq)Az%CfG`PAODPjNvyy-x1gzZd6OSZ;71h z`SY!)=5L)ACJA{1Gpl?rV1aVD0ip~bzS_(t!ibXw9qu`dypXFh&RwkgfxZ#*-gYLY zs0`=0-k^O*Az0p+|K;TtamEr1QP_=9Fve7;5SeeNi!i#1cbe(y>Pfm`fUWM1wFtX_ zrf_PXX?I!L0x>3sEz2-o0n*2#OLODjv3^VQ|K?ftD`9ipZ&_LgDIjS*a#;R>*?^?P zZxxiEDkg4(UcgIN9zet(q@#h@C!rFfFn)$~*ErJl>a76i06s~g1n^HAN=HvZZT}Z4 zGo~DIJm{~Ge}sTz0!rSE_S%2U2p^gB$i5h!x(5bIOP}w{c9@BiUVwM7;NPm_;5f1R zg3S^zY4pQhx{1LNKSQgNL|+N&ZWFv?P-bBH72aDy*FlV0>Ge|oWA?l8+(tc0jwxL| z=A%Qq*}FpSlP5KLQ&4IEZKgs6Nn(R2c&mpL8+Jngf}$5q6mo^GdOR~cvV>=XSb>OL z(87Sy`vR#1#qif#DzI+-_%%R|#2c`E=gv~}gbRzqNd%>UhE^Y+Pa#Ys0ACB4#DLFDl$1Zg@tY#}@gK0s72*PO*siP~_zIn4;a zsikLQ!2)IkdHhOfsAcC@MrJa(a9Wshv)p?MbIKcw&U zWRSRFVzRjied{HZ0!T--wY3>oS>@4Y6PpdXxEyo84?xg?XX}U@JdN(7G0n)o*ai#b z20j8sJx1jLB;UR9g`S=ppfPyya6=K?)}+J24p7a%?nWP;C4YcX7W2IQ;xre|l^$Sd z5ECHD_^iL~mnl05|2jhlyjVMLTMHRTw8Gf%8FT_au-&A*?Y--Mjdwz{0Av;ASl=0Ja!1Ag4I+*>iK3R^5C8J@tIT39 z&hFwBU+~rata034=-Dt8ueBII3rFpl>&}>i@qw@A+9=W(@>O|lb9=`?C8Kf)-xwIk zxA=AmWQR3>-N;B5^^4e(Fc&Uki;zwMT4ly765(CUjEwum4O)SHf};C@Z^x)5>o~UF zEu7l(OG_tg$F4#h2=nPzAhxK_czYQ0P%^bIU%p{zc!3C1mX(;9nQK2@^2g)J*JuS6 z$P<+iqm>jc@LDtZ##zkRm`B#(_yOPeHa7M#s9YR#yvF_;*Kn-yQdx_BzaN7z@fX4` zD`?*K0~L1g_j^wPmqL^=@(=jRqZB@lv!Fo%Cw~X+!FLERU~f==@COMA+cDYzcQ~N< z-Fb%U4_e<_KVO&Ohlqy*phD0sDZr1x_*ezjt;DGhoDXCEO%UfO3{0h!J}+%kT`|Cs zs6o613|NPwm@aBS0EX`XSgrPDK}2bw6CivJp5j9ET`Y9@UG_L1kX@pW?;jPO?LPYP zuHoT$6+wvR5E*b``&MGxGBNpB%Yasf+>@bIvrGvXnThXbeQLPF9FjXGF<-3xE)>#G1E;+XTCcK+M(nHusr z+N-WOlm`dfQK9qdBz*TsCR!PeD^5+{ym5mue5Um0&s+F_3mL2;Rx#86VAKQn_%~rl zV<+&b01IAg^gZp1*uz^H7$`;1h|CYX_oWay5Io~Q-blWI^7*WTU~gRtx~}UWcb4IM zoE~maHe#FPRY&dvf_KhfsKxAcSnTg=^du^qD9n7MLnU}JBp(`!ozB8@TC#*ra-1D` z9{mV!G8}xxZ=h%df>$`W4=_5w9x(jxRO(Ahy-4l^)*=ND$^$YS1ip~d2KjXbg7b{$ zLPj2tR7C745}2^Mx?~RFiVq-7(yYMXNnB1i;%=Ck{Y1}9*@>tQfN4nZAkZDb0k}2d zrknYr%FqwxTz1kxgFX*x_!RueM5{^=L@>c9SNGw`0?`<&hoF1KR{|I3&C$n0GT({a zeqc({ zifr4Rzk@sFo>AA;{KaTd>YTLz;YZM_&S5Z>^H@p3crS1bq1%3hw?otyMw4H#Y!PL^ zf$z|~RdDF7iO4({KSJkVwhs;t2ERo->_A?RLtzn6t`sWhLrmGPf(iS<<+n%JLS`r= z?o0?Rhy8PFEKV`!#h1`i0yM-~TbpoY2L^d)^-e=FifII<0xAli7^rydmgkQ{A}I}N zAP%{PKrTffL^#rp9N^SW)MBO|jGv3p610XHz5VxO{ zkuwdOQ@_-XVjhw~~`LK>o)>{}Tv?gMQjYDd&$+M|1R?JC2HfZ4iA ztKg`LAR8={{~;eVZ5BXhRaRd9f}KMr>+bb}rIQG;>O@UcEups7y&9NJnM}c~@EbZL z(s~++jcX)1a;ZuUmzO_zNd&UcJ{9b~$R9G2{_ie$G;i=jWSx0o4;8Pr2?}0A(#cGA*W$r3)kebQi z>akDMEBuMGC!lRZ8EZibQu5FcPmi^-Iyx>n)x4FjiLd!wSy@ShzkZ?*w=y$F`!O}a z3xpbye5;ZL5)jZAH&HZCn}w&NHS8Q7UMdmfd#Xyy0l56-whAe;%U(gL=9pVah${}x zsCbf_iNQWfpbDR*`imxPdw^)Evi0bwsi}cb;>$cGjFtXF^>=OU{u3wEu7|>9Ato*k zHS7E0tR8|Ru|BiM+#JdI5ZxLCM_<9C5PKY$SMqRB@UNl8a^R3ouBoo>0?}BTs1pj& zBc9_czLgvVch#P;Usz(pDTS!S`>1#O&OKGhaYcX+u@u9cf69I`Qks6fuB7|20U9KA zhrdn8tblunv}(jp1`Qhc=664)rC%y;XsEC6gG}kHp57jO213l@O=4X~0%FEdK)qqZ z26DDS3=FgF2au!H67c0MK%TY$nR=~e6`mI=F2O6%dLtI;Dbg!n27Lfv^%U+nj9)(J zqvgg~HbHH8;)FlUgPUPlBRucmcT)e~WB4F4KtySwdBmWZ#H03l(#*|u`uBM_A2tJL zz9!9IKU$;)yRT2-Udhq#pvFIL}@)A+x$C9{+YuBz7T;jpG)(hVgscofi ze6%iIQd3j28EYv+eSo_C({z}fj-YPo%JV!}$FTb~g88rCSD}EUHf$efDPF03Zs04CVl(4d}lmo+^2X}`})q@_0u%wIO^Yil^Jw0yl zkwE@J(_{z0CN9kc*-(c8`baKMP-7jJ{QmtM2z;I(YZBD!0L+Mno17(RAAZ49utu^1 zWo?&)(<$^a%uROd0(x6BdD1Hi(X#;rdTXWA<{QILCOEw8Lg0tUNXDJ+wA^a10i&Zq z39PnGYU_b=y<)C-3CEEozR@*vg?JF|b8)o{<~bfzs|Y z@S=Q*jE_qpf^DQ)bhtLzJ$m$r^m8Q2m7GaCckU#^CT1*q%z&ieNKB^u<<#cK(AB+$Hb^d`$bUqjB_|oZ1f^879P0jzk$uZYw0IfXIG(gkyqvdaDX<_tG(*0RNdzdE| z3UQJsZD<%HP`F3;jSo~zqUdz##PGorA4@h^{J(;tEY779FCI0;3P%_&2op z8Q4&_3h_I;}n%9s81e8v;KYdS7r4zclDx7kuJ29RR(Hc0-wj<}j}F z23TQ=z(dv1ly{alWf{+n{#0Yve)4Qc$q^@QVC`U? zuK_wDkPIH)UwHN>N19Hc)GGp1j@5`~1_h=M(y-c34FYP1RHrca-4n>lVS#X!@K7lqv*2)}`}t9ZZC2@*9xL>NG# z20f1=u$#ntexaA7Fa|%LJ2Q=e3qu-qT?D`Z{H7MmHnYZ0op)T9Q3s=5Yr{(eDSk$y z-hqS+qdc@X*zYlSY^66j2)jS!$lJok#)dYGOcUt0#T=$D4BFVMMRaGmCGFY)RSw@pF8If+|0$;Ouia8+41h-6iP2*?8H9nL^DQn zh7XmIM3BHT!`x`O;0KSrOc2RR`5tET45j~^c!#~*kZ<$>2>CDBLRbMe{D#Gr?-x!NRm zPYTW#m|sZ2^K-n;&dhu;-<2KH=X#nNJ7{Bm00Sfp31DV|BVodm{I+Vwk*n6bTfTmEgF3;f0-`ZOXJ2Tz_|UsM zuTV}+ZM%++j{BitpQleDswxXQt>m;}ZG;`;70Pc6ZLh*I;j#nvwWYMOgO!o-D-c%- z@$BOaBuo$>J|}}awJ}l{1<5P~GzT^}&C3mdz@RmmiW04_t^Eph32a+`sa4m|h>Ro9 z;4}UKlRB!xDflNLjI5p%1C!8?f&xgLWO@TCA$+aft``aiNzyM-_lJbY;&dS)9Dw@1 z|NQw3e9@8JSN`+u{$bE;6O9bOhrI>=1i_xs?8CE1dmq#t+8OX^+&m39Vvt4}Z`F$S z*9b0uNV?n;ecwtX-RXxa012BuIF7*5U(_xDR7OC3pyMP^AJgHsy9OEs(E?~ew~+o84-JR>Ar6ky*9);bq4Hz94>+S>zKfPi`4?%MK`uoMLtoGt zP_AeNw5_k|O%=L9BoXaL=O!vD3d5bS?bvQ?EsW#mBhcj^K-gTsYB*>s032lL)ZToI z)VGq7Xc@^TDt7qEooHWNq1&noLC1^kl?)jn3I`3WmE`2Q;LD;J7eHVa^r7ns)=nxn zPPPp_E*mhc$WPQbWqtf8QJmrW|*7ZR4#|A_k)x153tT9}X(ph;kj zM>k5iRVXe{pdmJK$Dm{tuqoU8ctvTcO5=GnK-f{$zep@I775=(7P^wN^KRoG-nCuR z?DfdTH&HA@H~|s`0wyssv}18`@${H*{H4@`5dH)W#o*gOWKXp-a5d-ywK|f&*>Y%I ztjg8#Ip;KakN%awNMzruP2ac%%D1kH{}Zwu)6uM0t=)E-Cgxr~toxbBCgl^gWQXp=}AMKeT^8A3-eBR0s#shg#5W( z1h5C3eZ>%%^XkUNzbn?w4ywPaf*nD!uF)B&+w~Dd5wK_q%$303Lxghum|KCP_VthuF9|Vr9X`gf++&M=Ha8}Fk^y` zO4`W&ZQ$Dz^{rxU)I%bkhjqv0=w(!rio@r63$GrIezoy{(TIBQj&Aeru#x|` z@6q5txcrR||BDnoYA>&Z_lQX^y&KKA$2M9H01Cu$NamN`UU$@wG$?j#2sMhhh4uH5 z{(41_^v;#tn3T<0xBI( zYu*U~fgWT+k*&+%^z7T-7qsJih-liaq3k zrwejeckTL%Pz{8LenC?f+3!zo=s=I(K3^2?cc(YU{FL>rKcPa5p z1A>vGhFbzkMR{|bdN*7f`v_-SwFT-pBs6Mx6{1!}Wo}Pll2AY~xDBeVy8Wbp0J)rq zXaP`C09wr~&HGNah`d}ypP~iw~fh6P-_zFM})+g%7W3|OV zR|R~Cgz8i?@%G%BQ-o&T!nFf=O<&L%L6d4wP=y>Bk_7#rMNA1A8T7V0@F~y~p`@;d z!?YLG#Mn6BHWWZWv4;IOZzh~Z`+0fiJoiDv1(axFqVvq)?z|rr6||>0*O4G*0!@K+ z-iPo{Kvow9FM`#)0F62Yod?7edmy=l_6!Cf0vlr4fpFKX#IpT*NdYV8Zg(}+w`6G20V=d6wF!RcjhTsDL1 zL--%n)uU*zNGFWtj%#gpfi57PdDNsQXf9sBhX>g+>MBX5x&Fn2DqAZUWl_`a@$U)< zq~%d)BVQ|GIDi@kJ7o{Ta0sn|32z5gBn9U%jn*I-QGhBpZr$>L+6V+TRLqmGyb(0i zYERW0$=kCd|5gUTKFI%TNAX`>=-)#@=K2>RM1h7As6I`9Z(Fxk%Z5J0nufk)B&qMcjW@ za>GB4iAm1&Gb_*Jm`sJ8oY-7HhLtXdIR2`0p7N5()uc8Ydm;K##QdVUNCvC)RCFsP zq}*CH2v};tO2iTB*1lhn*S|Xr>*Zazx$vet=3?dZ@HakuzjFc>6|Eq2#8qMs@nxX% zKs*=qeLxesd3I8O{Bc}RbKuM+dW*ik66l3VY=p=@ zB`yW5X42x6RP#Z~0Ojk%+}v$AIY6FFfYt)u6}V;?dPzkL;@YXVw)FM&MMg!zXXO}VX4r!jfWc{< ze*gyd!(A-6F^U2NuOCCe2VfA0Y2g~ZSYV8@jLV7Aie_N6C$SmGq74J-itsNl>4vE@ z7X58Z%sji?{GIOzocw27_{lgRN9=LV_Ytmwvo!1$_H?&@j=pogV>?u5q!i^XI6Wlz z{G8ILtengGqJ_6BKXe)&m8JgqWW1ukZQD)8*@zFfF9@qymWRBeew%cA`k!mkbV*{ON-YMV)7Em{F*|(!_C4@9GL1N`3UB*Mf%aK4r zB;FE21_8=w7I5`L@}n8~Sma3hx1YyNiSaFGTqwS;^HYICiJdfB@Zm z1aPUC_WMicL!%=jF_+W1fn@rdHS=A`INE*>W*iE@cl`%^$F~=aw-;)sSTH{KHMi_* z;cdsHN`GU@ z%K593Y(IaPXZXDe{2Fa)Zu>y%Wy;X>SzoR+)226)LW+laG{^id-_l%vWOcW)Nz2WH zzL(^C4u7j{r1fE?x0l0Nt==?zmaYspj;G%Dsbdfn$F_ z&IJH}9f~F@DDnw90fU=se84d;-#r8n4&tCU(C2jEswD_nP@w^^dx3L?s`0sbpc49y z0r(Cvk7Fya0F@?#GBkXKU?d}&`_Dit_yh_-{FpRkxKNnf>;K6HR)J^BE{u4xa~Imq zVTXpCy0;fCa-hdCPN653sEH5$Ip3Za0-qc_b>Svb;G^d{_*94a|8yHg;}4ea7SRpB(9NIl1%|I{qJ}IdR8;&Wumw7r&9r2{e7QJ$C z)A+&xM&@+vqR7boQEK^;R+;1L?+r9wUhnjmM~`kLAov5lu$>9d%pIZc`Qy9O*(*N2 z78Vg%lo88ucWzbQIsYwF%r(5ow{RAczU^BS6$J!z3p4}eKldqmt7e?C8a%ZPox7%^ zaztLpI`WwFLe9q4o!_sNC4$lzJ}J8{u4qCD_rg_iHdREIMPiZnt$miVprs~t^dhoy86Gh0|olk(zX z30esWv&mfzX7e?f{p$MsGta_p$NAT@v5DJ^Pwic>-`%TFIaup*_}R=Az@YBAe|FRD z){(zatu_)TbggJdYMXIkhM=_u@0Hgq!p@hQH7GLlGIoy#9ykSii-s*d@?yx-bgq0T zyc9CrN%u zv~qXN7zNT}G#$9f&Ebo}BKrh`Cm1r50aY-A7 zoye#FCzqWOR*$DvJF}UcSb`4fKxZz!^(w7ILg0#5=m1eU_ue@q!`H(`j>g`gwhC3o zR}g7Uoqp<0^Kil@T;fT`y;>cMl+>7Ow;pF?m>Frw+R+*rm0c}xczxzj{r24~Dv{1G z9XzjFkHS-3R=CL_+@mbRVb5<*s@7mJYiM zvC34ycnMOD!jw<7(Adb8z__S?~09ScyU`b;xg{|R&~syeovfy0UjXQOJb=F4hf;^fc6$*PvU!HIHOzacy<~e$2%i~cx6aY z25_H@$PXhOfYH)-y`DVTcj(ZD`7P7>%S!J(m;q#uEupb+>PNq(3|)u5@*lxTxh1XZ1u$dAyp z$1QB%Bt6LWm-FZu>h06hn*$Q%FL-f^9?EsQGkpu?W3c`9L01RmeY~7;Y4QHP$rtT0 zAx-fFRdDjI%5v4lj3AYWI0;C08Ztis!^^qTFj2^DArKSZ0Bl396hulX6fRLI7;Bax z<#j@OAfEhC!m~`QPekok;v~z0FB0RcpU1bgojQl&b@Ov}G*R0w6G2^yi>(;#6pt@U^j>UZY>9+5Cd{S=Qh!<2atkWFKc@**aX;Zq+U`|&w zCPILtEG++W{h;96Cn5rd;csHa@Pn7lQN}p5D6`gAj@_70M(9J3rpDy);E>&Zyuaeg`?BLt8rd*dnR_zTEFI5taXmh&Bktic4Lx#F8{0;~z;IvVmYV8}MZ0Pr&5C76<+D`o|?c(i`;f!koMSB)3 zyQiz;UOaq%C|`K&gY3^Esm0h53kHL~oGRp>IOi)zc03QB-kcQhURH5CeSGYbl<*Cw zYf8fI#Wc`d33b@H>lJQ*k^PbC>DIbw??gFgxJ)a|DDr0zo_aOGKFclTENE`@tY`vt z|5T7`=)mZq3!Cf8Rq3MYnQg70h5F+N(#hUB8wDW~eC1E3*_N_5Xdo|4^Awe^9uxOTX>Uaa`?<#K*a{Uae-|2d!|S zUjIVpMI9YQ>-6!F8)FlcGKpEf6yKE!%UlVWf|ix7XZtNgb6zVY)vJH2W>Vg|bEmZI zpfKg5<_T7z!dMG~uq&3i391{O-e`_Io8YkO6`|(!Y~<&LVb>>LIt|mVoO#$&LKudl z%(T$+AssDF=cjeV;`O`^x~=APzOF9W9>4rH@-jR2TkkPe)Tn)>pIH|$zr;EaaO!^W zKsa2{G*|5v-nl$Vsx$s&n6~rFyPdBWi%#bECpdfZ4|eWo%xV&^b8_aBu`$@B*U3cH zHTujYSAKMx46BUW_sP`Nxf{pU>eNgPIqne&3v7EF>)qyfcj#1sa6xGIp*crg<>L3w zA4OlEz13EBYfpSk{BVb{nZh=X;tvZ;tck*6Lvw;g0%HuvJ**Gx%?s^f@rYFHkZGeD zbWTfsQdf6%I_0mMw>Qo5a!aU2ZmaluNPiyH8Lun4ksPD>XNH4nuhZqEuT%{!Q$WlNYL8}FJlHOE`ZUKuwS=WQSB1lywzc2n4|&ycTQHQhGPX}%|0iu{&5d`C z+)mM=GHEPx`T?d9zYVjv4$A)AuvOyx8ku>4x%=6wV_RGtUw5$V+*#c-qR%NJx^Ry6 z_nInU4lUsu@h7b$Jv?zN@ja((l}O0Q*y1U4hvKt7_e*o1$j1zkP4u0X`%k+m=W08h zTkzKz+kE^=v%@o=C-MizEWPwQuFh}U#L!t&aO9Ntx9aKA;QD*YQmVSYGmMsKX;PD$ zj!K*e$Yhuye;~E#sQ#X%poa|iI<0ajTLS71)YdW|N$e~Y((AZ;Dsqm~>DgMZ344Ry z9gX>}+dow8j>^c`meuUh;_i55eC)xg5jvNwc~|eYC7D&T$wJYdO@1E#HvMiYd+gTb zaV_=IsgR&=WqBXtd6#O$w8w@W0S7QJGnb6UX1AOed8}F3pH2I{>2EL1k6R~fa&;b_ zncuyMfs?o9Zu!=r3imqWZ~FYtolOqeh^xgtz=FD}~OV*EzEjRo&On`r>v;Q{k(dxe2e7r8;?rOEr z?jH+okDC6y&B=L%JE(_-hFE+aT=4Sx$qO5~AG;SrrEw$eJ&`c&1kJ0wJLo*Ab6bD5 zy(-+kH8~VQ*l7oG{8MU^g0=O@-rnBY+S+S^p~>1u`DZzu0@r#m@EB$#+lquWnh5Tu z+aMUa#lK8OL!-_&pFt9T%qEvDwdrY{v1!^LIUAi#dIzZXeq|D98LgeDPMhi8dpV*0 z#P@(Nokg8@?E+)}6^`U=E09|w;}xIfq>{gCvl3;@DzV;9Y3Iuhh5{x0yJigigNiTj zvOmxEt@60>y`XIEV!6u0xL@F_kIWjGG*KjNBI)x(O{|W$*4j1c zns{gv!|y~ys7Xor@NtH|cu{=QSG<0pATEv@QPVSbTt-PmKJ;GqhHAMeP}C7CqI}@d z!3oO{v~WuH_QDVa=66|$ggwJkL1>GzS86o;85D#Ik4u#9?fr6 zM){j{EKTWt;VN2oyACRHa{z+*khQiJl3eH9d~^BsP{`UzNEUP+S5__;H2?P(svIr; z)MTQrsi~@^)h`j6o3f24VG*K9E?UW-Yr6?c0&#I5qwg8Hf4Z?zn_PT_><(x&51lx% z{jT{bP%Iw54fjJ*2iAT9OaQ<0qLqmtD72v!<4>bFelb1syUBn zP@=;1j-~~um zphi7VB=v)M!3{(-uK%JN1RDmeNQirGs}g88^y~WpdP7(X5=9CEAFM5^YXFXjE$}|1 z?+x3x5sDE&BD`~XK5w-wX z6!^=KT5ugcY&hM_<7|%mPl|!`3d0CU5}*mX3MLvZRdPdR4Mv$~x?3a^9XQ@8HjCI@ z#`PZ1YGKO&*|jf`!_|g3`v}DZz#m2&-`4amIX(r@nP(AW>2`7qFJEZm9ML-)6#@NI}ATJT!5g#vp zu9+QJ_1$@VO;82l_wSwBO+msUBEsom;~;8iXrHN|Fr`MWEYgh<7jy72Nv0GWYRq$W znLQwZVA)4dBO8C4ka$LDQ9!L7((+@pJd0N&$0% z=^U(r;ZBpXuqjI+7||xT^~9z<0)=h(c(4I|?Y!$35O5ayAv_m_C*!|c=1K;ww&6-2 zNK5fNNc1WK1c?h8OUAMCO+!#xRBPxys=bW4^;;$bb|WDU>l)LI&$k;SlL_-N7B{(R zthA~MUnI+2^G0|REJ1o%e$!i`K!E};T?nMCS7&_tCjHj{w3EmCuM(Tuj7JQKH_Uau zNSnkZ4%1GnSo3$`vLTlcfZb{B+A7<2L6$XRRv43GPkd5ASMqoEn-E+O+#B;FS+YW* zl_gkcrInRYF)1t=%x)o@|iTHxFg=sWo1~e211i*hn17Ax_Y&` zuDP!uRNT?AtC8_ty2zOzaNBfHq@hbAcVmMrC0A#RrsT;fDpsE4Y2VeXbG0=+!|@D! zA@`p?*tlm0tW5ySasMwd!sO+NrOR(CzI}l{GGK46)#pwtRnG6;j-`AT+d0JjSpBC@ zC#E*~sjN;VZWXI`<%p1^TV-mf{lwXSOn%Pkto5N&_KF)h6L)BA_`Ppm&fn=}gPmV; z;x3&_RmBDHRrboqZ%(jCTnaQm#ZhUq*=*al_mZ$Z_j<|kfv@f! zGW@;IckFsG>*wah@a{~^Hjd|8h5k!=UbW*?MPN#JLVjpH?_Yj$rO6&%|w7Op3eqarP{@t%(L zeAR7RyQBN|E+2fYwBgWH%1)d9FFt__i@$EGYX(?Z8L@XSMN!74*63#47C0gF@yj|! zjhCqlp*KxlOT>Rp$^85K`I)%drlfI(oYvah-_Gq}Wf98bfAFpuc(0nwTP0t8FO8S6 zGQ7Wby@y>f_38M!LwAl%4Oy#*^i1ubnQx;1MZa+tq!h=Ag>$#Ln?uu46@Uue_1Gf}|riclYc3A@iu4 zxCB-o|A3@Zi}{J`ZMdZo4G`{u!F6BBxGM&o7>otiU;zkLI9N5zgEJa%$n)S}FSKLW zfXKFjzVJy!Xyq-`B>U0y29jEIn}sBmABT7tm+~ag>G2id5HS;j2FT~x*wuW8lYp?a zk20ehu&rw#7EL76Tn9nikgEQBJsS&$wRPR@Q`5WVe8Z`;lG<*io)mvk)~jl+>*87S zxUQ4q)8|VSq87>w%nTR8D$5HV*7&UXs-GHn@gT*Ya0a~w)&Us2MQ(+;1*ChAq>~Wf)>X5jZoqb8Y0azIl zeIbMoq6)nF_m>_DpKTC+Pyb!@~tX0!2q4^9QDT*;heZ zO@g1wTy%Pb3}*zXqk(d(nQ)vxUwpB<=KYi|GMtIz*4nxqzeFK6UK>z(0eyVY^&=HLv~k2f=t z`{(3l;wC8MG61+H4jek<3B9bqEBaVr-k>K?4d6zWby*))5(_;@h)LUGx!j6#y7#n*L?n@{9SP_RDMCKaxVKp%Sx z&z4*=fv~sbxT<(bZxb|3m18fU+G0UVfTI%EoIgR^e{TV@M`(@L6LYz&qM`x7K+_dN zHZ%Ba8b@u=Y?idI&I%!t6xPq10B-?{k~2-&FD?N@aNcya{C!8;dLW~$;<_p-_bYF` z&&q17`J;f#zNT)?`Ig~(Ynz~2)7IJ-7yk8=^Y^9KKPtMWL!xy~oD|mK?K4~Uvtbcm;WdH{= zw8N_E>OH%JvR{GAIswuX7cz(4+9Sl`L%i8=oY;>!cDnDt2g#nL?>T@z83P=WDAg`r zj9B!D%qJ>h=>P9^7O%^(nJL)CUEAYhn&@!fF4uY8hbFBK@xlR7)(AF8BPi1A#;r?Dlhk0RUiPUFpvXa9C z79!%0B>WEbFF#&4)&6B?JrgD0l|uh4?EA|5{+O#_48=j#cK2JF`;}bojVmZ?E8urM z=tI18_Y@60_qDYW4r}9a9($|Rb^6`=u1m*Ug}0VyPcKr)51(s4p))$^#vq|kr=va7 zNp*L~iY~r>MYGa^CA+szV2o8fw`}Ed!ZTTV$@7_^4)H1)YQpVKWjHj}Ffi@op39?+-SeR{ukLNR$1|$MMjn5FT~eP z&iAxiIu2=@#Ks#PRm}SDJTPWbYI;W^V0Xq#T>baLf|7J}MaMZq53y;z;rPHmxPW z6r+l59UrWZxG^;N4|)DQhrcRBTfb&vcG6y5ojLyM^FROCTh~}8TyUlTWLs6&I+nOQu#0wkm?Nbpy&BE?-xiDgudCM7@_rL87 z>?m&TXScI4CE4u4n|+FV=6seeL0Y11qCVf&c&j diff --git a/desktop/src/app/app.component.ts b/desktop/src/app/app.component.ts index 51f9ac9e9..9e77b4d27 100644 --- a/desktop/src/app/app.component.ts +++ b/desktop/src/app/app.component.ts @@ -50,27 +50,21 @@ export class AppComponent implements AfterViewInit { this.maximizable = e.resizable === 'true' }) - setTimeout(() => { - const size = document.getElementsByTagName('app-root')[0]?.getBoundingClientRect()?.height - - if (size) { - this.electron.send('WINDOW.RESIZE', Math.floor(size)) - } - }, 1000) + setTimeout(() => this.electron.autoResizeWindow(), 1000) } pin() { this.pinned = !this.pinned - if (this.pinned) this.electron.send('WINDOW.PIN') - else this.electron.send('WINDOW.UNPIN') + if (this.pinned) this.electron.pinWindow() + else this.electron.unpinWindow() } minimize() { - this.electron.send('WINDOW.MINIMIZE') + this.electron.minimizeWindow() } maximize() { - this.electron.send('WINDOW.MAXIMIZE') + this.electron.maximizeWindow() } close(data?: any) { diff --git a/desktop/src/app/filterwheel/filterwheel.component.html b/desktop/src/app/filterwheel/filterwheel.component.html index 75e344348..0c0ab0aee 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.html +++ b/desktop/src/app/filterwheel/filterwheel.component.html @@ -1,6 +1,6 @@

- - + +
- {{ item.regime }} - {{ item.id }} + {{ item?.regime }} + {{ item?.id }}
diff --git a/desktop/src/app/framing/framing.component.ts b/desktop/src/app/framing/framing.component.ts index a941ef600..747388c16 100644 --- a/desktop/src/app/framing/framing.component.ts +++ b/desktop/src/app/framing/framing.component.ts @@ -67,11 +67,11 @@ export class FramingComponent implements AfterViewInit, OnDestroy { electron.on('DATA.CHANGED', (event: FramingData) => { ngZone.run(() => this.frameFromData(event)) }) - } - ngAfterViewInit() { this.loadPreference() + } + ngAfterViewInit() { this.route.queryParams.subscribe(e => { const data = JSON.parse(decodeURIComponent(e.data)) as FramingData this.frameFromData(data) diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 22012de3d..6cb535f12 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -393,11 +393,11 @@ export class ImageComponent implements AfterViewInit, OnDestroy { hotkeys('ctrl+-', (event) => { event.preventDefault(); this.zoomOut() }) hotkeys('ctrl+=', (event) => { event.preventDefault(); this.zoomIn() }) hotkeys('ctrl+0', (event) => { event.preventDefault(); this.resetZoom() }) - } - ngAfterViewInit() { this.loadPreference() + } + ngAfterViewInit() { this.route.queryParams.subscribe(e => { const data = JSON.parse(decodeURIComponent(e.data)) as ImageData this.loadImageFromData(data) From 40bf5152c08e3ee095d227f7bb8844598cad6b27 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 24 Feb 2024 14:22:05 -0300 Subject: [PATCH 57/87] [api][desktop]: Retrieve HiPS Surveys from CDS MocServer API --- .../nebulosa/api/framing/FramingController.kt | 16 +- .../nebulosa/api/framing/FramingService.kt | 13 +- .../nebulosa/api/framing/HipsSurveyType.kt | 68 --- .../kotlin/nebulosa/api/image/ImageService.kt | 5 +- desktop/framing.png | Bin 18502 -> 19120 bytes .../src/app/framing/framing.component.html | 4 +- desktop/src/app/framing/framing.component.ts | 25 +- desktop/src/assets/data/hipsSurveys.json | 522 ------------------ desktop/src/shared/services/api.service.ts | 6 +- desktop/src/shared/types/framing.types.ts | 32 -- .../kotlin/nebulosa/hips2fits/Hips2Fits.kt | 15 + .../nebulosa/hips2fits/Hips2FitsService.kt | 7 +- .../kotlin/nebulosa/hips2fits/HipsSurvey.kt | 13 +- .../src/test/kotlin/Hips2FitsServiceTest.kt | 11 +- .../nebulosa/test/Hips2FitsStringSpec.kt | 2 +- 15 files changed, 86 insertions(+), 653 deletions(-) delete mode 100644 api/src/main/kotlin/nebulosa/api/framing/HipsSurveyType.kt delete mode 100644 desktop/src/assets/data/hipsSurveys.json diff --git a/api/src/main/kotlin/nebulosa/api/framing/FramingController.kt b/api/src/main/kotlin/nebulosa/api/framing/FramingController.kt index 35bc92e43..b9dd4ac30 100644 --- a/api/src/main/kotlin/nebulosa/api/framing/FramingController.kt +++ b/api/src/main/kotlin/nebulosa/api/framing/FramingController.kt @@ -8,18 +8,18 @@ import nebulosa.api.image.ImageService import nebulosa.math.deg import nebulosa.math.hours import org.hibernate.validator.constraints.Range -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController -import java.nio.file.Path +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("framing") class FramingController( private val imageService: ImageService, + private val framingService: FramingService, ) { + @GetMapping("hips-surveys") + fun hipsSurveys() = framingService.availableHipsSurveys + @PutMapping fun frame( @RequestParam @Valid @NotBlank rightAscension: String, @@ -28,8 +28,6 @@ class FramingController( @RequestParam(required = false, defaultValue = "720") @Valid @Range(min = 1, max = 4320) height: Int, @RequestParam(required = false, defaultValue = "1.0") @Valid @Positive @Max(90) fov: Double, @RequestParam(required = false, defaultValue = "0.0") rotation: Double, - @RequestParam(required = false, defaultValue = "CDS_P_DSS2_COLOR") hipsSurvey: HipsSurveyType, - ): Path { - return imageService.frame(rightAscension.hours, declination.deg, width, height, fov.deg, rotation.deg, hipsSurvey) - } + @RequestParam(required = false, defaultValue = "CDS/P/DSS2/COLOR") hipsSurvey: String, + ) = imageService.frame(rightAscension.hours, declination.deg, width, height, fov.deg, rotation.deg, hipsSurvey) } diff --git a/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt b/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt index 7250870eb..3c78ca249 100644 --- a/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt +++ b/api/src/main/kotlin/nebulosa/api/framing/FramingService.kt @@ -16,25 +16,28 @@ import kotlin.io.path.outputStream @Service class FramingService(private val hips2FitsService: Hips2FitsService) { + val availableHipsSurveys by lazy { hips2FitsService.availableSurveys().execute().body()!!.sorted() } + @Synchronized fun frame( rightAscension: Angle, declination: Angle, width: Int, height: Int, fov: Angle, rotation: Angle = 0.0, - hipsSurveyType: HipsSurveyType = HipsSurveyType.CDS_P_DSS2_COLOR, + id: String = "CDS/P/DSS2/COLOR", ): Triple? { val responseBody = hips2FitsService.query( - hipsSurveyType.hipsSurvey, - rightAscension, declination, - width, height, - rotation, fov, + id, rightAscension, declination, + width, height, rotation, fov, format = FormatOutputType.FITS, ).execute().body() ?: return null responseBody.use { it.byteStream().transferAndCloseOutput(DEFAULT_PATH.outputStream()) } + val image = DEFAULT_PATH.fits().use(Image::open) val solution = PlateSolution.from(image.header) + LOG.info("framing file loaded. calibration={}", solution) + return Triple(image, solution, DEFAULT_PATH) } diff --git a/api/src/main/kotlin/nebulosa/api/framing/HipsSurveyType.kt b/api/src/main/kotlin/nebulosa/api/framing/HipsSurveyType.kt deleted file mode 100644 index c90dc8dc7..000000000 --- a/api/src/main/kotlin/nebulosa/api/framing/HipsSurveyType.kt +++ /dev/null @@ -1,68 +0,0 @@ -package nebulosa.api.framing - -import nebulosa.hips2fits.HipsSurvey - -enum class HipsSurveyType(val hipsSurvey: HipsSurvey) { - CDS_P_DSS2_NIR("CDS/P/DSS2/NIR", "Image/Optical/DSS", "equatorial", "Optical", 16, 2.236E-4, 0.9955), - CDS_P_DSS2_BLUE("CDS/P/DSS2/blue", "Image/Optical/DSS", "equatorial", "Optical", 16, 2.236E-4, 0.9972), - CDS_P_DSS2_COLOR("CDS/P/DSS2/color", "Image/Optical/DSS", "equatorial", "Optical", 0, 2.236E-4, 1.0), - CDS_P_DSS2_RED("CDS/P/DSS2/red", "Image/Optical/DSS", "equatorial", "Optical", 16, 2.236E-4, 1.0), - FZU_CZ_P_CTA_FRAM_SURVEY_B("fzu.cz/P/CTA-FRAM/survey/B", "Image/Optical/CTA-FRAM", "equatorial", "Optical", -64, 0.003579, 1.0), - FZU_CZ_P_CTA_FRAM_SURVEY_R("fzu.cz/P/CTA-FRAM/survey/R", "Image/Optical/CTA-FRAM", "equatorial", "Optical", -64, 0.003579, 1.0), - FZU_CZ_P_CTA_FRAM_SURVEY_V("fzu.cz/P/CTA-FRAM/survey/V", "Image/Optical/CTA-FRAM", "equatorial", "Optical", -64, 0.003579, 1.0), - FZU_CZ_P_CTA_FRAM_SURVEY_COLOR("fzu.cz/P/CTA-FRAM/survey/color", "Image/Optical/CTA-FRAM", "equatorial", "Optical", 0, 0.003579, 1.0), - CDS_P_2MASS_H("CDS/P/2MASS/H", "Image/Infrared/2MASS", "equatorial", "Infrared", -32, 2.236E-4, 1.0), - CDS_P_2MASS_J("CDS/P/2MASS/J", "Image/Infrared/2MASS", "equatorial", "Infrared", -32, 2.236E-4, 1.0), - CDS_P_2MASS_K("CDS/P/2MASS/K", "Image/Infrared/2MASS", "equatorial", "Infrared", -32, 2.236E-4, 1.0), - CDS_P_2MASS_COLOR("CDS/P/2MASS/color", "Image/Infrared/2MASS", "equatorial", "Infrared", 0, 2.236E-4, 1.0), - CDS_P_AKARI_FIS_COLOR("CDS/P/AKARI/FIS/Color", "Image/Infrared/AKARI-FIS", "equatorial", "Infrared", 0, 0.003579, 1.0), - CDS_P_AKARI_FIS_N160("CDS/P/AKARI/FIS/N160", "Image/Infrared/AKARI-FIS", "equatorial", "Infrared", -32, 0.003579, 1.0), - CDS_P_AKARI_FIS_N60("CDS/P/AKARI/FIS/N60", "Image/Infrared/AKARI-FIS", "equatorial", "Infrared", -32, 0.003579, 1.0), - CDS_P_AKARI_FIS_WIDEL("CDS/P/AKARI/FIS/WideL", "Image/Infrared/AKARI-FIS", "equatorial", "Infrared", -32, 0.003579, 1.0), - CDS_P_AKARI_FIS_WIDES("CDS/P/AKARI/FIS/WideS", "Image/Infrared/AKARI-FIS", "equatorial", "Infrared", -32, 0.003579, 1.0), - CDS_P_NEOWISER_COLOR("CDS/P/NEOWISER/Color", "Image/Infrared/WISE/NEOWISER", "equatorial", "Infrared", 0, 4.473E-4, 1.0), - CDS_P_NEOWISER_W1("CDS/P/NEOWISER/W1", "Image/Infrared/WISE/NEOWISER", "equatorial", "Infrared", -32, 4.473E-4, 1.0), - CDS_P_NEOWISER_W2("CDS/P/NEOWISER/W2", "Image/Infrared/WISE/NEOWISER", "equatorial", "Infrared", -32, 4.473E-4, 1.0), - CDS_P_WISE_WSSA_12UM("CDS/P/WISE/WSSA/12um", "Image/Infrared/WISE/WSSA", "equatorial", "Infrared", -32, 8.946E-4, 1.0), - CDS_P_ALLWISE_W1("CDS/P/allWISE/W1", "Image/Infrared/WISE", "equatorial", "Infrared", -32, 4.473E-4, 1.0), - CDS_P_ALLWISE_W2("CDS/P/allWISE/W2", "Image/Infrared/WISE", "equatorial", "Infrared", -32, 4.473E-4, 1.0), - CDS_P_ALLWISE_W3("CDS/P/allWISE/W3", "Image/Infrared/WISE", "equatorial", "Infrared", -32, 4.473E-4, 0.9999), - CDS_P_ALLWISE_W4("CDS/P/allWISE/W4", "Image/Infrared/WISE", "equatorial", "Infrared", -32, 4.473E-4, 1.0), - CDS_P_ALLWISE_COLOR("CDS/P/allWISE/color", "Image/Infrared/WISE", "equatorial", "Infrared", 0, 4.473E-4, 1.0), - CDS_P_UNWISE_W1("CDS/P/unWISE/W1", "Image/Infrared/WISE/unWISE", "equatorial", "Infrared", -32, 0.229, 1.0), - CDS_P_UNWISE_W2("CDS/P/unWISE/W2", "Image/Infrared/WISE/unWISE", "equatorial", "Infrared", -32, 0.229, 1.0), - CDS_P_UNWISE_COLOR_W2_W1W2_W1("CDS/P/unWISE/color-W2-W1W2-W1", "Image/Infrared/WISE/unWISE", "equatorial", "Infrared", -32, 4.473E-4, 1.0), - CDS_P_RASS("CDS/P/RASS", "Image/X/ROSAT", "equatorial", "X-ray", 16, 0.007157, 1.0), - JAXA_P_ASCA_GIS("JAXA/P/ASCA_GIS", "Image/X/ASCA", "equatorial", "X-ray", 0, 0.001789, 1.0), - JAXA_P_ASCA_SIS("JAXA/P/ASCA_SIS", "Image/X/ASCA", "equatorial", "X-ray", 0, 0.001789, 1.0), - JAXA_P_MAXI_GSC("JAXA/P/MAXI-GSC", "Image/X/MAXI", "equatorial", "X-ray", 0, 0.001789, 1.0), - JAXA_P_MAXI_SSC("JAXA/P/MAXI-SSC", "Image/X/MAXI", "equatorial", "X-ray", 0, 0.1145, 1.0), - JAXA_P_SUZAKU("JAXA/P/SUZAKU", "Image/X", "equatorial", "X-ray", 0, 0.001789, 1.0), - JAXA_P_SWIFT_BAT_FLUX("JAXA/P/SWIFT_BAT_FLUX", "Image/X", "equatorial", "X-ray", 0, 0.001789, 1.0), - CDS_P_EGRET_DIF_100_150("CDS/P/EGRET/Dif/100-150", "Image/Gamma-ray/EGRET/Diffuse", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_EGRET_DIF_1000_2000("CDS/P/EGRET/Dif/1000-2000", "Image/Gamma-ray/EGRET/Diffuse", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_EGRET_DIF_150_300("CDS/P/EGRET/Dif/150-300", "Image/Gamma-ray/EGRET/Diffuse", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_EGRET_DIF_2000_4000("CDS/P/EGRET/Dif/2000-4000", "Image/Gamma-ray/EGRET/Diffuse", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_EGRET_DIF_30_50("CDS/P/EGRET/Dif/30-50", "Image/Gamma-ray/EGRET/Diffuse", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_EGRET_DIF_300_500("CDS/P/EGRET/Dif/300-500", "Image/Gamma-ray/EGRET/Diffuse", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_EGRET_DIF_4000_10000("CDS/P/EGRET/Dif/4000-10000", "Image/Gamma-ray/EGRET/Diffuse", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_EGRET_DIF_50_70("CDS/P/EGRET/Dif/50-70", "Image/Gamma-ray/EGRET/Diffuse", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_EGRET_DIF_500_1000("CDS/P/EGRET/Dif/500-1000", "Image/Gamma-ray/EGRET/Diffuse", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_EGRET_DIF_70_100("CDS/P/EGRET/Dif/70-100", "Image/Gamma-ray/EGRET/Diffuse", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_EGRET_INF100("CDS/P/EGRET/inf100", "Image/Gamma-ray/EGRET", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_EGRET_SUP100("CDS/P/EGRET/sup100", "Image/Gamma-ray/EGRET", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_FERMI_3("CDS/P/Fermi/3", "Image/Gamma-ray", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_FERMI_4("CDS/P/Fermi/4", "Image/Gamma-ray", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_FERMI_5("CDS/P/Fermi/5", "Image/Gamma-ray", "equatorial", "Gamma-ray", -32, 0.01431, 1.0), - CDS_P_FERMI_COLOR("CDS/P/Fermi/color", "Image/Gamma-ray", "equatorial", "Gamma-ray", 0, 0.01431, 1.0); - - constructor( - id: String, - category: String, - frame: String, - regime: String, - bitPix: Int, - pixelScale: Double, - skyFraction: Double, - ) : this(HipsSurvey(id, category, frame, regime, bitPix, pixelScale, skyFraction)) -} diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt index c972b38e3..8a21dc3d6 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt @@ -6,7 +6,6 @@ import nebulosa.api.atlas.SimbadEntityRepository import nebulosa.api.calibration.CalibrationFrameService import nebulosa.api.connection.ConnectionService import nebulosa.api.framing.FramingService -import nebulosa.api.framing.HipsSurveyType import nebulosa.fits.* import nebulosa.imaging.Image import nebulosa.imaging.ImageChannel @@ -238,10 +237,10 @@ class ImageService( fun frame( rightAscension: Angle, declination: Angle, width: Int, height: Int, fov: Angle, - rotation: Angle = 0.0, hipsSurveyType: HipsSurveyType = HipsSurveyType.CDS_P_DSS2_COLOR, + rotation: Angle = 0.0, id: String = "CDS/P/DSS2/COLOR", ): Path { val (image, calibration, path) = framingService - .frame(rightAscension, declination, width, height, fov, rotation, hipsSurveyType)!! + .frame(rightAscension, declination, width, height, fov, rotation, id)!! imageBucket.put(path, image, calibration) return path } diff --git a/desktop/framing.png b/desktop/framing.png index 802a5fdb3f0dc10aaee087e15b83b0a21d79c546..a76aca2ffec17b431301f562db267834518f5005 100644 GIT binary patch literal 19120 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 literal 18502 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 NU&_7671i
- {{ item?.regime }} + {{ item?.regime }} ({{ item?.skyFraction | percent:'1.1-1' }}) {{ item?.id }}
- {{ item.regime }} + {{ item.regime }} ({{ item?.skyFraction | percent:'1.1-1' }}) {{ item.id }}
diff --git a/desktop/src/app/framing/framing.component.ts b/desktop/src/app/framing/framing.component.ts index 747388c16..e7d8d9b70 100644 --- a/desktop/src/app/framing/framing.component.ts +++ b/desktop/src/app/framing/framing.component.ts @@ -1,7 +1,6 @@ import { AfterViewInit, Component, HostListener, NgZone, OnDestroy } from '@angular/core' import { ActivatedRoute } from '@angular/router' import { MessageService } from 'primeng/api' -import hipsSurveys from '../../assets/data/hipsSurveys.json' import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' import { ElectronService } from '../../shared/services/electron.service' @@ -44,8 +43,8 @@ export class FramingComponent implements AfterViewInit, OnDestroy { height = 720 fov = 1.0 rotation = 0.0 - readonly hipsSurveys: HipsSurvey[] = hipsSurveys - hipsSurvey: HipsSurvey = hipsSurveys[0] + hipsSurveys: HipsSurvey[] = [] + hipsSurvey?: HipsSurvey loading = false @@ -71,7 +70,19 @@ export class FramingComponent implements AfterViewInit, OnDestroy { this.loadPreference() } - ngAfterViewInit() { + async ngAfterViewInit() { + this.hipsSurveys = await this.api.hipsSurveys() + + if (this.hipsSurvey) { + this.hipsSurvey = this.hipsSurveys.find(e => e.id === this.hipsSurvey!.id) + } + + if (!this.hipsSurvey) { + this.hipsSurvey = this.hipsSurveys[0] + } + + setTimeout(() => this.electron.autoResizeWindow(), 1000) + this.route.queryParams.subscribe(e => { const data = JSON.parse(decodeURIComponent(e.data)) as FramingData this.frameFromData(data) @@ -130,7 +141,11 @@ export class FramingComponent implements AfterViewInit, OnDestroy { this.height = preference.height ?? 720 this.fov = preference.fov ?? 1 this.rotation = preference.rotation ?? 0 - this.hipsSurvey ??= preference.hipsSurvey ?? this.hipsSurvey + + if (preference.hipsSurvey) { + this.hipsSurveys = [preference.hipsSurvey] + this.hipsSurvey = this.hipsSurveys[0] + } } private savePreference() { diff --git a/desktop/src/assets/data/hipsSurveys.json b/desktop/src/assets/data/hipsSurveys.json deleted file mode 100644 index 7b1f5f902..000000000 --- a/desktop/src/assets/data/hipsSurveys.json +++ /dev/null @@ -1,522 +0,0 @@ -[ - { - "type": "CDS_P_DSS2_NIR", - "id": "CDS/P/DSS2/NIR", - "category": "Image/Optical/DSS", - "frame": "equatorial", - "regime": "Optical", - "bitPix": 16, - "pixelScale": 2.236E-4, - "skyFraction": 0.9955 - }, - { - "type": "CDS_P_DSS2_BLUE", - "id": "CDS/P/DSS2/blue", - "category": "Image/Optical/DSS", - "frame": "equatorial", - "regime": "Optical", - "bitPix": 16, - "pixelScale": 2.236E-4, - "skyFraction": 0.9972 - }, - { - "type": "CDS_P_DSS2_COLOR", - "id": "CDS/P/DSS2/color", - "category": "Image/Optical/DSS", - "frame": "equatorial", - "regime": "Optical", - "bitPix": 0, - "pixelScale": 2.236E-4, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_DSS2_RED", - "id": "CDS/P/DSS2/red", - "category": "Image/Optical/DSS", - "frame": "equatorial", - "regime": "Optical", - "bitPix": 16, - "pixelScale": 2.236E-4, - "skyFraction": 1.0 - }, - { - "type": "FZU_CZ_P_CTA_FRAM_SURVEY_B", - "id": "fzu.cz/P/CTA-FRAM/survey/B", - "category": "Image/Optical/CTA-FRAM", - "frame": "equatorial", - "regime": "Optical", - "bitPix": -64, - "pixelScale": 0.003579, - "skyFraction": 1.0 - }, - { - "type": "FZU_CZ_P_CTA_FRAM_SURVEY_R", - "id": "fzu.cz/P/CTA-FRAM/survey/R", - "category": "Image/Optical/CTA-FRAM", - "frame": "equatorial", - "regime": "Optical", - "bitPix": -64, - "pixelScale": 0.003579, - "skyFraction": 1.0 - }, - { - "type": "FZU_CZ_P_CTA_FRAM_SURVEY_V", - "id": "fzu.cz/P/CTA-FRAM/survey/V", - "category": "Image/Optical/CTA-FRAM", - "frame": "equatorial", - "regime": "Optical", - "bitPix": -64, - "pixelScale": 0.003579, - "skyFraction": 1.0 - }, - { - "type": "FZU_CZ_P_CTA_FRAM_SURVEY_COLOR", - "id": "fzu.cz/P/CTA-FRAM/survey/color", - "category": "Image/Optical/CTA-FRAM", - "frame": "equatorial", - "regime": "Optical", - "bitPix": 0, - "pixelScale": 0.003579, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_2MASS_H", - "id": "CDS/P/2MASS/H", - "category": "Image/Infrared/2MASS", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 2.236E-4, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_2MASS_J", - "id": "CDS/P/2MASS/J", - "category": "Image/Infrared/2MASS", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 2.236E-4, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_2MASS_K", - "id": "CDS/P/2MASS/K", - "category": "Image/Infrared/2MASS", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 2.236E-4, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_2MASS_COLOR", - "id": "CDS/P/2MASS/color", - "category": "Image/Infrared/2MASS", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": 0, - "pixelScale": 2.236E-4, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_AKARI_FIS_COLOR", - "id": "CDS/P/AKARI/FIS/Color", - "category": "Image/Infrared/AKARI-FIS", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": 0, - "pixelScale": 0.003579, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_AKARI_FIS_N160", - "id": "CDS/P/AKARI/FIS/N160", - "category": "Image/Infrared/AKARI-FIS", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 0.003579, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_AKARI_FIS_N60", - "id": "CDS/P/AKARI/FIS/N60", - "category": "Image/Infrared/AKARI-FIS", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 0.003579, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_AKARI_FIS_WIDEL", - "id": "CDS/P/AKARI/FIS/WideL", - "category": "Image/Infrared/AKARI-FIS", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 0.003579, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_AKARI_FIS_WIDES", - "id": "CDS/P/AKARI/FIS/WideS", - "category": "Image/Infrared/AKARI-FIS", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 0.003579, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_NEOWISER_COLOR", - "id": "CDS/P/NEOWISER/Color", - "category": "Image/Infrared/WISE/NEOWISER", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": 0, - "pixelScale": 4.473E-4, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_NEOWISER_W1", - "id": "CDS/P/NEOWISER/W1", - "category": "Image/Infrared/WISE/NEOWISER", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 4.473E-4, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_NEOWISER_W2", - "id": "CDS/P/NEOWISER/W2", - "category": "Image/Infrared/WISE/NEOWISER", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 4.473E-4, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_WISE_WSSA_12UM", - "id": "CDS/P/WISE/WSSA/12um", - "category": "Image/Infrared/WISE/WSSA", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 8.946E-4, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_ALLWISE_W1", - "id": "CDS/P/allWISE/W1", - "category": "Image/Infrared/WISE", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 4.473E-4, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_ALLWISE_W2", - "id": "CDS/P/allWISE/W2", - "category": "Image/Infrared/WISE", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 4.473E-4, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_ALLWISE_W3", - "id": "CDS/P/allWISE/W3", - "category": "Image/Infrared/WISE", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 4.473E-4, - "skyFraction": 0.9999 - }, - { - "type": "CDS_P_ALLWISE_W4", - "id": "CDS/P/allWISE/W4", - "category": "Image/Infrared/WISE", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 4.473E-4, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_ALLWISE_COLOR", - "id": "CDS/P/allWISE/color", - "category": "Image/Infrared/WISE", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": 0, - "pixelScale": 4.473E-4, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_UNWISE_W1", - "id": "CDS/P/unWISE/W1", - "category": "Image/Infrared/WISE/unWISE", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 0.229, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_UNWISE_W2", - "id": "CDS/P/unWISE/W2", - "category": "Image/Infrared/WISE/unWISE", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 0.229, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_UNWISE_COLOR_W2_W1W2_W1", - "id": "CDS/P/unWISE/color-W2-W1W2-W1", - "category": "Image/Infrared/WISE/unWISE", - "frame": "equatorial", - "regime": "Infrared", - "bitPix": -32, - "pixelScale": 4.473E-4, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_RASS", - "id": "CDS/P/RASS", - "category": "Image/X/ROSAT", - "frame": "equatorial", - "regime": "X-ray", - "bitPix": 16, - "pixelScale": 0.007157, - "skyFraction": 1.0 - }, - { - "type": "JAXA_P_ASCA_GIS", - "id": "JAXA/P/ASCA_GIS", - "category": "Image/X/ASCA", - "frame": "equatorial", - "regime": "X-ray", - "bitPix": 0, - "pixelScale": 0.001789, - "skyFraction": 1.0 - }, - { - "type": "JAXA_P_ASCA_SIS", - "id": "JAXA/P/ASCA_SIS", - "category": "Image/X/ASCA", - "frame": "equatorial", - "regime": "X-ray", - "bitPix": 0, - "pixelScale": 0.001789, - "skyFraction": 1.0 - }, - { - "type": "JAXA_P_MAXI_GSC", - "id": "JAXA/P/MAXI-GSC", - "category": "Image/X/MAXI", - "frame": "equatorial", - "regime": "X-ray", - "bitPix": 0, - "pixelScale": 0.001789, - "skyFraction": 1.0 - }, - { - "type": "JAXA_P_MAXI_SSC", - "id": "JAXA/P/MAXI-SSC", - "category": "Image/X/MAXI", - "frame": "equatorial", - "regime": "X-ray", - "bitPix": 0, - "pixelScale": 0.1145, - "skyFraction": 1.0 - }, - { - "type": "JAXA_P_SUZAKU", - "id": "JAXA/P/SUZAKU", - "category": "Image/X", - "frame": "equatorial", - "regime": "X-ray", - "bitPix": 0, - "pixelScale": 0.001789, - "skyFraction": 1.0 - }, - { - "type": "JAXA_P_SWIFT_BAT_FLUX", - "id": "JAXA/P/SWIFT_BAT_FLUX", - "category": "Image/X", - "frame": "equatorial", - "regime": "X-ray", - "bitPix": 0, - "pixelScale": 0.001789, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_EGRET_DIF_100_150", - "id": "CDS/P/EGRET/Dif/100-150", - "category": "Image/Gamma-ray/EGRET/Diffuse", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_EGRET_DIF_1000_2000", - "id": "CDS/P/EGRET/Dif/1000-2000", - "category": "Image/Gamma-ray/EGRET/Diffuse", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_EGRET_DIF_150_300", - "id": "CDS/P/EGRET/Dif/150-300", - "category": "Image/Gamma-ray/EGRET/Diffuse", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_EGRET_DIF_2000_4000", - "id": "CDS/P/EGRET/Dif/2000-4000", - "category": "Image/Gamma-ray/EGRET/Diffuse", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_EGRET_DIF_30_50", - "id": "CDS/P/EGRET/Dif/30-50", - "category": "Image/Gamma-ray/EGRET/Diffuse", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_EGRET_DIF_300_500", - "id": "CDS/P/EGRET/Dif/300-500", - "category": "Image/Gamma-ray/EGRET/Diffuse", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_EGRET_DIF_4000_10000", - "id": "CDS/P/EGRET/Dif/4000-10000", - "category": "Image/Gamma-ray/EGRET/Diffuse", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_EGRET_DIF_50_70", - "id": "CDS/P/EGRET/Dif/50-70", - "category": "Image/Gamma-ray/EGRET/Diffuse", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_EGRET_DIF_500_1000", - "id": "CDS/P/EGRET/Dif/500-1000", - "category": "Image/Gamma-ray/EGRET/Diffuse", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_EGRET_DIF_70_100", - "id": "CDS/P/EGRET/Dif/70-100", - "category": "Image/Gamma-ray/EGRET/Diffuse", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_EGRET_INF100", - "id": "CDS/P/EGRET/inf100", - "category": "Image/Gamma-ray/EGRET", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_EGRET_SUP100", - "id": "CDS/P/EGRET/sup100", - "category": "Image/Gamma-ray/EGRET", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_FERMI_3", - "id": "CDS/P/Fermi/3", - "category": "Image/Gamma-ray", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_FERMI_4", - "id": "CDS/P/Fermi/4", - "category": "Image/Gamma-ray", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_FERMI_5", - "id": "CDS/P/Fermi/5", - "category": "Image/Gamma-ray", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": -32, - "pixelScale": 0.01431, - "skyFraction": 1.0 - }, - { - "type": "CDS_P_FERMI_COLOR", - "id": "CDS/P/Fermi/color", - "category": "Image/Gamma-ray", - "frame": "equatorial", - "regime": "Gamma-ray", - "bitPix": 0, - "pixelScale": 0.01431, - "skyFraction": 1.0 - } -] \ No newline at end of file diff --git a/desktop/src/shared/services/api.service.ts b/desktop/src/shared/services/api.service.ts index 0cdd19e1f..60a5a9798 100644 --- a/desktop/src/shared/services/api.service.ts +++ b/desktop/src/shared/services/api.service.ts @@ -526,11 +526,15 @@ export class ApiService { // FRAMING + hipsSurveys() { + return this.http.get('framing/hips-surveys') + } + frame(rightAscension: Angle, declination: Angle, width: number, height: number, fov: number, rotation: number, hipsSurvey: HipsSurvey, ) { - const query = this.http.query({ rightAscension, declination, width, height, fov, rotation, hipsSurvey: hipsSurvey.type }) + const query = this.http.query({ rightAscension, declination, width, height, fov, rotation, hipsSurvey: hipsSurvey.id }) return this.http.put(`framing?${query}`) } diff --git a/desktop/src/shared/types/framing.types.ts b/desktop/src/shared/types/framing.types.ts index c4f335584..48472023a 100644 --- a/desktop/src/shared/types/framing.types.ts +++ b/desktop/src/shared/types/framing.types.ts @@ -1,36 +1,4 @@ -export const HIPS_SURVEY_TYPES = [ - 'CDS_P_DSS2_NIR', - 'CDS_P_DSS2_BLUE', 'CDS_P_DSS2_COLOR', - 'CDS_P_DSS2_RED', 'FZU_CZ_P_CTA_FRAM_SURVEY_B', - 'FZU_CZ_P_CTA_FRAM_SURVEY_R', 'FZU_CZ_P_CTA_FRAM_SURVEY_V', - 'FZU_CZ_P_CTA_FRAM_SURVEY_COLOR', 'CDS_P_2MASS_H', - 'CDS_P_2MASS_J', 'CDS_P_2MASS_K', - 'CDS_P_2MASS_COLOR', 'CDS_P_AKARI_FIS_COLOR', - 'CDS_P_AKARI_FIS_N160', 'CDS_P_AKARI_FIS_N60', - 'CDS_P_AKARI_FIS_WIDEL', 'CDS_P_AKARI_FIS_WIDES', - 'CDS_P_NEOWISER_COLOR', 'CDS_P_NEOWISER_W1', - 'CDS_P_NEOWISER_W2', 'CDS_P_WISE_WSSA_12UM', - 'CDS_P_ALLWISE_W1', 'CDS_P_ALLWISE_W2', - 'CDS_P_ALLWISE_W3', 'CDS_P_ALLWISE_W4', - 'CDS_P_ALLWISE_COLOR', 'CDS_P_UNWISE_W1', - 'CDS_P_UNWISE_W2', 'CDS_P_UNWISE_COLOR_W2_W1W2_W1', - 'CDS_P_RASS', 'JAXA_P_ASCA_GIS', - 'JAXA_P_ASCA_SIS', 'JAXA_P_MAXI_GSC', - 'JAXA_P_MAXI_SSC', 'JAXA_P_SUZAKU', - 'JAXA_P_SWIFT_BAT_FLUX', 'CDS_P_EGRET_DIF_100_150', - 'CDS_P_EGRET_DIF_1000_2000', 'CDS_P_EGRET_DIF_150_300', - 'CDS_P_EGRET_DIF_2000_4000', 'CDS_P_EGRET_DIF_30_50', - 'CDS_P_EGRET_DIF_300_500', 'CDS_P_EGRET_DIF_4000_10000', - 'CDS_P_EGRET_DIF_50_70', 'CDS_P_EGRET_DIF_500_1000', - 'CDS_P_EGRET_DIF_70_100', 'CDS_P_EGRET_INF100', - 'CDS_P_EGRET_SUP100', 'CDS_P_FERMI_3', - 'CDS_P_FERMI_4', 'CDS_P_FERMI_5', 'CDS_P_FERMI_COLOR' -] as const - -export type HipsSurveyType = (typeof HIPS_SURVEY_TYPES)[number] - export interface HipsSurvey { - type: HipsSurveyType | string id: string category: string frame: string diff --git a/nebulosa-hips2fits/src/main/kotlin/nebulosa/hips2fits/Hips2Fits.kt b/nebulosa-hips2fits/src/main/kotlin/nebulosa/hips2fits/Hips2Fits.kt index e8a5a9ef1..106e50c09 100644 --- a/nebulosa-hips2fits/src/main/kotlin/nebulosa/hips2fits/Hips2Fits.kt +++ b/nebulosa-hips2fits/src/main/kotlin/nebulosa/hips2fits/Hips2Fits.kt @@ -16,4 +16,19 @@ internal interface Hips2Fits { @Query("coordsys") coordSystem: String, @Query("rotation_angle") rotationAngle: Double, @Query("format") format: String, ): Call + + /** + * MOC Server tool for retrieving as fast as possible the list of astronomical data sets + * (35531 catalogs, surveys, ... harvested from CDS and VO servers) having at least + * one observation in a specifical sky region/and or time range. + * + * The default result is an ID list. MOC Server is based on Multi-Order Coverage maps (MOC) + * described in the IVOA REC. standard. + * + * Query example: `hips_service_url*=*alasky* && dataproduct_type=image && moc_sky_fraction >= 0.99` + * + * @see Web page + */ + @GET("MocServer/query?get=record&fmt=json") + fun availableSurveys(@Query("expr") expr: String): Call> } diff --git a/nebulosa-hips2fits/src/main/kotlin/nebulosa/hips2fits/Hips2FitsService.kt b/nebulosa-hips2fits/src/main/kotlin/nebulosa/hips2fits/Hips2FitsService.kt index e883dd623..888c00588 100644 --- a/nebulosa-hips2fits/src/main/kotlin/nebulosa/hips2fits/Hips2FitsService.kt +++ b/nebulosa-hips2fits/src/main/kotlin/nebulosa/hips2fits/Hips2FitsService.kt @@ -22,7 +22,7 @@ class Hips2FitsService( * the center of projection, the type of projection and the field of view. */ fun query( - hips: HipsSurvey, + id: String, ra: Angle, dec: Angle, width: Int = 1200, height: Int = 900, rotation: Angle = 0.0, @@ -31,10 +31,13 @@ class Hips2FitsService( coordSystem: CoordinateFrameType = CoordinateFrameType.ICRS, format: FormatOutputType = FormatOutputType.FITS, ) = service.query( - hips.id, ra.toDegrees, dec.toDegrees, width, height, projection.name, fov.toDegrees, + id, ra.toDegrees, dec.toDegrees, width, height, projection.name, fov.toDegrees, coordSystem.name.lowercase(), rotation.toDegrees, format.name.lowercase(), ) + fun availableSurveys() = service + .availableSurveys("ID=CDS* && hips_service_url*=*alasky* && dataproduct_type=image && moc_sky_fraction >= 0.99 && obs_regime=Optical,Infrared,UV,Radio,X-ray,Gamma-ray") + companion object { const val MAIN_URL = "https://alasky.cds.unistra.fr/" diff --git a/nebulosa-hips2fits/src/main/kotlin/nebulosa/hips2fits/HipsSurvey.kt b/nebulosa-hips2fits/src/main/kotlin/nebulosa/hips2fits/HipsSurvey.kt index b107b5889..8bae56328 100644 --- a/nebulosa-hips2fits/src/main/kotlin/nebulosa/hips2fits/HipsSurvey.kt +++ b/nebulosa-hips2fits/src/main/kotlin/nebulosa/hips2fits/HipsSurvey.kt @@ -11,4 +11,15 @@ data class HipsSurvey( @field:JsonProperty("bitPix") @field:JsonAlias("hips_pixel_bitpix") val bitPix: Int = 0, @field:JsonProperty("pixelScale") @field:JsonAlias("hips_pixel_scale") val pixelScale: Double = 0.0, @field:JsonProperty("skyFraction") @field:JsonAlias("moc_sky_fraction") val skyFraction: Double = 0.0, -) +) : Comparable { + + override fun compareTo(other: HipsSurvey): Int { + if (regime == other.regime) return -skyFraction.compareTo(other.skyFraction) + return REGIME_SORT_ORDER.indexOf(regime).compareTo(REGIME_SORT_ORDER.indexOf(other.regime)) + } + + companion object { + + @JvmStatic private val REGIME_SORT_ORDER = arrayOf("Optical", "Infrared", "UV", "Radio", "X-ray", "Gamma-ray") + } +} diff --git a/nebulosa-hips2fits/src/test/kotlin/Hips2FitsServiceTest.kt b/nebulosa-hips2fits/src/test/kotlin/Hips2FitsServiceTest.kt index 62818de0a..1f5adf9dc 100644 --- a/nebulosa-hips2fits/src/test/kotlin/Hips2FitsServiceTest.kt +++ b/nebulosa-hips2fits/src/test/kotlin/Hips2FitsServiceTest.kt @@ -1,10 +1,11 @@ import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.doubles.shouldBeExactly import io.kotest.matchers.ints.shouldBeExactly import io.kotest.matchers.nulls.shouldNotBeNull import nebulosa.fits.* import nebulosa.hips2fits.Hips2FitsService -import nebulosa.hips2fits.HipsSurvey import nebulosa.io.source import nebulosa.math.deg import nebulosa.math.toDegrees @@ -15,7 +16,8 @@ class Hips2FitsServiceTest : StringSpec() { val service = Hips2FitsService() "query" { - val responseBody = service.query(HipsSurvey("CDS/P/DSS2/red"), 201.36506337683.deg, (-43.01911250808).deg) + val responseBody = service + .query("CDS/P/DSS2/red", 201.36506337683.deg, (-43.01911250808).deg) .execute() .body() .shouldNotBeNull() @@ -26,5 +28,10 @@ class Hips2FitsServiceTest : StringSpec() { hdu.rightAscension.toDegrees shouldBeExactly 201.36506337683 hdu.declination.toDegrees shouldBeExactly -43.01911250808 } + "available surveys" { + val surveys = service.availableSurveys().execute().body().shouldNotBeNull() + surveys.shouldNotBeEmpty() + surveys shouldHaveSize 115 + } } } diff --git a/nebulosa-test/src/main/kotlin/nebulosa/test/Hips2FitsStringSpec.kt b/nebulosa-test/src/main/kotlin/nebulosa/test/Hips2FitsStringSpec.kt index 5c5c730b6..89f5715fa 100644 --- a/nebulosa-test/src/main/kotlin/nebulosa/test/Hips2FitsStringSpec.kt +++ b/nebulosa-test/src/main/kotlin/nebulosa/test/Hips2FitsStringSpec.kt @@ -27,7 +27,7 @@ abstract class Hips2FitsStringSpec : FitsStringSpec() { } HIPS_SERVICE - .query(CDS_P_DSS2_NIR, centerRA, centerDEC, 1280, 720, 0.0, fov) + .query(CDS_P_DSS2_NIR.id, centerRA, centerDEC, 1280, 720, 0.0, fov) .execute() .body()!! .use { it.byteStream().transferAndCloseOutput(path.outputStream()) } From 3a9903b473558c9b62f01b4b0e17ff16bfe93141 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 24 Feb 2024 18:50:24 -0300 Subject: [PATCH 58/87] [api]: Fix server-side connection close handling --- .../api/connection/ConnectionEventHandler.kt | 2 +- .../nebulosa/api/connection/ConnectionService.kt | 9 +++++---- .../nebulosa/indi/device/DeviceEventHandler.kt | 14 ++++++++++++-- .../indi/protocol/parser/INDIProtocolReader.kt | 7 ++----- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionEventHandler.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionEventHandler.kt index a761977ef..715c1def3 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionEventHandler.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionEventHandler.kt @@ -22,7 +22,7 @@ class ConnectionEventHandler( private val focuserEventHandler: FocuserEventHandler, private val wheelEventHandler: WheelEventHandler, private val guideOutputEventHandler: GuideOutputEventHandler, -) : DeviceEventHandler { +) : DeviceEventHandler.EventReceived { @Suppress("CascadeIf") override fun onEventReceived(event: DeviceEvent<*>) { diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt index 8bd268290..2485b2e72 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt @@ -5,6 +5,7 @@ import nebulosa.api.messages.MessageService import nebulosa.indi.client.INDIClient import nebulosa.indi.client.connection.INDISocketConnection import nebulosa.indi.device.Device +import nebulosa.indi.device.DeviceEventHandler import nebulosa.indi.device.INDIDeviceProvider import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.filterwheel.FilterWheel @@ -58,17 +59,17 @@ class ConnectionService( val provider = when (type) { ConnectionType.INDI -> { val client = INDIClient(host, port) - client.registerDeviceEventHandler(eventBus::post) + client.registerDeviceEventHandler(DeviceEventHandler.EventReceived(eventBus::post)) client.registerDeviceEventHandler(connectionEventHandler) - client.registerDeviceEventHandler { sendConnectionClosedEvent(client) } + client.registerDeviceEventHandler(DeviceEventHandler.ConnectionClosed { sendConnectionClosedEvent(client) }) client.start() client } else -> { val client = AlpacaClient(host, port, alpacaHttpClient) - client.registerDeviceEventHandler(eventBus::post) + client.registerDeviceEventHandler(DeviceEventHandler.EventReceived(eventBus::post)) client.registerDeviceEventHandler(connectionEventHandler) - client.registerDeviceEventHandler { sendConnectionClosedEvent(client) } + client.registerDeviceEventHandler(DeviceEventHandler.ConnectionClosed { sendConnectionClosedEvent(client) }) client.discovery() client } diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceEventHandler.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceEventHandler.kt index 0a6d33df2..4b6e11806 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceEventHandler.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceEventHandler.kt @@ -1,8 +1,18 @@ package nebulosa.indi.device -fun interface DeviceEventHandler { +interface DeviceEventHandler { fun onEventReceived(event: DeviceEvent<*>) - fun onConnectionClosed() = Unit + fun onConnectionClosed() + + fun interface EventReceived : DeviceEventHandler { + + override fun onConnectionClosed() = Unit + } + + fun interface ConnectionClosed : DeviceEventHandler { + + override fun onEventReceived(event: DeviceEvent<*>) = Unit + } } diff --git a/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/INDIProtocolReader.kt b/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/INDIProtocolReader.kt index f7b884a12..2ccd92661 100644 --- a/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/INDIProtocolReader.kt +++ b/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/INDIProtocolReader.kt @@ -2,7 +2,6 @@ package nebulosa.indi.protocol.parser import nebulosa.log.loggerFor import java.io.Closeable -import java.net.SocketException class INDIProtocolReader( private val parser: INDIProtocolParser, @@ -47,12 +46,10 @@ class INDIProtocolReader( parser.close() } catch (_: InterruptedException) { running = false - LOG.info("protocol parser interrupted") - } catch (e: SocketException) { - listeners.onEach { it.onConnectionClosed() }.clear() - LOG.info("protocol parser socket error") + LOG.error("protocol parser interrupted") } catch (e: Throwable) { running = false + listeners.onEach { it.onConnectionClosed() }.clear() LOG.error("protocol parser error", e) parser.close() } From 86f157ad54f14e7bdd7ea81f9fc11b0c0f1a1dc4 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 24 Feb 2024 19:01:28 -0300 Subject: [PATCH 59/87] [desktop]: Fix exposure time normalization when open Camera as dialog --- .../src/app/alignment/alignment.component.ts | 17 +++++++++++------ desktop/src/app/camera/camera.component.ts | 11 +++++++---- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/desktop/src/app/alignment/alignment.component.ts b/desktop/src/app/alignment/alignment.component.ts index d0cad9edd..201074a33 100644 --- a/desktop/src/app/alignment/alignment.component.ts +++ b/desktop/src/app/alignment/alignment.component.ts @@ -279,12 +279,17 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { async showCameraDialog() { if (this.camera.name) { - if (this.tab === 0 && await CameraComponent.showAsDialog(this.browserWindow, 'TPPA', this.camera, this.tppaRequest.capture)) { - this.savePreference() - } else if (this.tab === 1 && await CameraComponent.showAsDialog(this.browserWindow, 'DARV', this.camera, this.darvRequest.capture)) { - this.savePreference() - this.darvRequest.exposureTime = this.darvRequest.capture.exposureTime / 1000000 - this.darvRequest.initialPause = this.darvRequest.capture.exposureDelay + if (this.tab === 0) { + if (await CameraComponent.showAsDialog(this.browserWindow, 'TPPA', this.camera, this.tppaRequest.capture)) { + this.savePreference() + } + } else if (this.tab === 1) { + this.darvRequest.capture.exposureTime = this.darvRequest.exposureTime * 1000000 + this.darvRequest.capture.exposureDelay = this.darvRequest.initialPause + + if (await CameraComponent.showAsDialog(this.browserWindow, 'DARV', this.camera, this.darvRequest.capture)) { + this.savePreference() + } } } } diff --git a/desktop/src/app/camera/camera.component.ts b/desktop/src/app/camera/camera.component.ts index 7bc2c8e62..eb05365a1 100644 --- a/desktop/src/app/camera/camera.component.ts +++ b/desktop/src/app/camera/camera.component.ts @@ -44,7 +44,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { } get canExposureTimeUnit() { - return this.mode !== 'TPPA' && this.mode !== 'DARV' + return this.mode !== 'DARV' } get canExposureAmount() { @@ -199,8 +199,9 @@ export class CameraComponent implements AfterContentInit, OnDestroy { this.request.frameType = 'FLAT' } else if (mode === 'TPPA') { this.exposureMode = 'FIXED' - this.exposureTimeUnit = ExposureTimeUnit.SECOND this.request.exposureAmount = 1 + } else if (mode === 'DARV') { + this.exposureTimeUnit = ExposureTimeUnit.SECOND } } @@ -321,12 +322,14 @@ export class CameraComponent implements AfterContentInit, OnDestroy { } private updateExposureUnit(unit: ExposureTimeUnit, from: ExposureTimeUnit = this.exposureTimeUnit) { - if (this.camera.exposureMax) { + const exposureMax = this.camera.exposureMax || 60000000 + + if (exposureMax) { const a = CameraComponent.exposureUnitFactor(from) const b = CameraComponent.exposureUnitFactor(unit) const exposureTime = Math.trunc(this.request.exposureTime * b / a) const exposureTimeMin = Math.trunc(this.camera.exposureMin * b / 60000000) - const exposureTimeMax = Math.trunc(this.camera.exposureMax * b / 60000000) + const exposureTimeMax = Math.trunc(exposureMax * b / 60000000) this.exposureTimeMax = Math.max(1, exposureTimeMax) this.exposureTimeMin = Math.max(1, exposureTimeMin) this.request.exposureTime = Math.max(this.exposureTimeMin, Math.min(exposureTime, this.exposureTimeMax)) From 0b1c5f5fe02b29d7b1485911eaa6fa9eeffe46d2 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 24 Feb 2024 19:03:33 -0300 Subject: [PATCH 60/87] [docs]: Update license copyright year --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index b6f689142..19ff7ef8f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022-2023 Tiago Melo +Copyright (c) 2022-2024 Tiago Melo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 7c7de789897bd518b96c27a741312c25e0254a6a Mon Sep 17 00:00:00 2001 From: tiagohm Date: Mon, 26 Feb 2024 00:05:01 -0300 Subject: [PATCH 61/87] [desktop]: Allow apply a sequence entry for other entries --- .../app/sequencer/sequencer.component.html | 8 +- .../src/app/sequencer/sequencer.component.ts | 115 +++++++++++++++--- 2 files changed, 106 insertions(+), 17 deletions(-) diff --git a/desktop/src/app/sequencer/sequencer.component.html b/desktop/src/app/sequencer/sequencer.component.html index 12a56df4a..81a75ef9d 100644 --- a/desktop/src/app/sequencer/sequencer.component.html +++ b/desktop/src/app/sequencer/sequencer.component.html @@ -200,14 +200,14 @@ (onClick)="showCameraDialog(entry)" size="small" />
+ - -
-
+
diff --git a/desktop/src/app/filterwheel/filterwheel.component.ts b/desktop/src/app/filterwheel/filterwheel.component.ts index 28c16648b..13ddf3191 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.ts +++ b/desktop/src/app/filterwheel/filterwheel.component.ts @@ -66,6 +66,10 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy { const wasConnected = this.wheel.connected Object.assign(this.wheel, event.device) this.update() + + if (wasConnected !== event.device.connected) { + setTimeout(() => electron.autoResizeWindow(), 1000) + } }) } }) diff --git a/desktop/src/shared/services/browser-window.service.ts b/desktop/src/shared/services/browser-window.service.ts index c1961d0ad..b11f6aaa8 100644 --- a/desktop/src/shared/services/browser-window.service.ts +++ b/desktop/src/shared/services/browser-window.service.ts @@ -25,12 +25,12 @@ export class BrowserWindowService { } openMount(options: OpenWindowOptionsWithData) { - Object.assign(options, { icon: 'telescope', width: 400, height: 469 }) + Object.assign(options, { icon: 'telescope', width: 400, height: 477 }) this.openWindow({ ...options, id: `mount.${options.data.name}`, path: 'mount' }) } openCamera(options: OpenWindowOptionsWithData) { - Object.assign(options, { icon: 'camera', width: 400, height: 476 }) + Object.assign(options, { icon: 'camera', width: 400, height: 470 }) return this.openWindow({ ...options, id: `camera.${options.data.name}`, path: 'camera' }) } @@ -45,7 +45,7 @@ export class BrowserWindowService { } openWheel(options: OpenWindowOptionsWithData) { - Object.assign(options, { icon: 'filter-wheel', width: 300, height: 283 }) + Object.assign(options, { icon: 'filter-wheel', width: 285, height: 195 }) this.openWindow({ ...options, id: `wheel.${options.data.name}`, path: 'wheel' }) } @@ -55,7 +55,7 @@ export class BrowserWindowService { } openGuider(options: OpenWindowOptions = {}) { - Object.assign(options, { icon: 'guider', width: 425, height: 450 }) + Object.assign(options, { icon: 'guider', width: 425, height: 438 }) this.openWindow({ ...options, id: 'guider', path: 'guider', data: undefined }) } @@ -79,17 +79,17 @@ export class BrowserWindowService { } openSkyAtlas(options: OpenWindowOptionsWithData) { - Object.assign(options, { icon: 'atlas', width: 450, height: 523 }) + Object.assign(options, { icon: 'atlas', width: 450, height: 565 }) this.openWindow({ ...options, id: 'atlas', path: 'atlas' }) } openFraming(options: OpenWindowOptionsWithData) { - Object.assign(options, { icon: 'framing', width: 280, height: 310 }) + Object.assign(options, { icon: 'framing', width: 280, height: 303 }) this.openWindow({ ...options, id: 'framing', path: 'framing' }) } openAlignment(options: OpenWindowOptions = {}) { - Object.assign(options, { icon: 'star', width: 450, height: 360 }) + Object.assign(options, { icon: 'star', width: 450, height: 343 }) this.openWindow({ ...options, id: 'alignment', path: 'alignment', data: undefined }) } @@ -99,12 +99,12 @@ export class BrowserWindowService { } openFlatWizard(options: OpenWindowOptions = {}) { - Object.assign(options, { icon: 'star', width: 410, height: 330 }) + Object.assign(options, { icon: 'star', width: 410, height: 326 }) this.openWindow({ ...options, id: 'flat-wizard', path: 'flat-wizard', data: undefined }) } openSettings(options: OpenWindowOptions = {}) { - Object.assign(options, { icon: 'settings', width: 580, height: 445 }) + Object.assign(options, { icon: 'settings', width: 580, height: 451 }) this.openWindow({ ...options, id: 'settings', path: 'settings', data: undefined }) } @@ -114,6 +114,6 @@ export class BrowserWindowService { } openAbout() { - this.openWindow({ id: 'about', path: 'about', icon: 'about', width: 480, height: 252, bringToFront: true, data: undefined }) + this.openWindow({ id: 'about', path: 'about', icon: 'about', width: 430, height: 246, bringToFront: true, data: undefined }) } } diff --git a/desktop/src/shared/services/electron.service.ts b/desktop/src/shared/services/electron.service.ts index d24c8e079..a26798b59 100644 --- a/desktop/src/shared/services/electron.service.ts +++ b/desktop/src/shared/services/electron.service.ts @@ -16,11 +16,11 @@ import { INDIMessageEvent } from '../types/device.types' import { FlatWizardElapsed } from '../types/flat-wizard.types' import { Focuser } from '../types/focuser.types' import { GuideOutput, Guider, GuiderHistoryStep, GuiderMessageEvent } from '../types/guider.types' +import { ConnectionClosed } from '../types/home.types' import { Mount } from '../types/mount.types' import { SequencerElapsed } from '../types/sequencer.types' import { FilterWheel } from '../types/wheel.types' import { ApiService } from './api.service' -import { ConnectionClosed } from '../types/home.types' type EventMappedType = { 'DEVICE.PROPERTY_CHANGED': INDIMessageEvent @@ -157,6 +157,34 @@ export class ElectronService { return this.send('JSON.READ', path) } + resizeWindow(size: number) { + this.send('WINDOW.RESIZE', Math.floor(size)) + } + + autoResizeWindow() { + const size = document.getElementsByTagName('app-root')[0]?.getBoundingClientRect()?.height + + if (size > 0) { + this.resizeWindow(size) + } + } + + pinWindow() { + this.send('WINDOW.PIN') + } + + unpinWindow() { + this.send('WINDOW.UNPIN') + } + + minimizeWindow() { + this.send('WINDOW.MINIMIZE') + } + + maximizeWindow() { + this.send('WINDOW.MAXIMIZE') + } + closeWindow(data: CloseWindow) { return this.send('WINDOW.CLOSE', data) } From 7329c47c9928306a319e291ba0f964dce720566c Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 24 Feb 2024 02:47:32 -0300 Subject: [PATCH 52/87] [desktop]: Save and restore the resizable window size --- desktop/app/main.ts | 46 ++++++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/desktop/app/main.ts b/desktop/app/main.ts index 62c492189..c49e13a96 100644 --- a/desktop/app/main.ts +++ b/desktop/app/main.ts @@ -16,7 +16,7 @@ const browserWindows = new Map() const modalWindows = new Map void }>() let api: ChildProcessWithoutNullStreams | null = null let apiPort = 7000 -let wsClient: Client +let webSocket: Client const args = process.argv.slice(1) const serve = args.some(e => e === '--serve') @@ -29,10 +29,10 @@ function createMainWindow() { createWindow({ id: 'home', path: 'home', data: undefined }) - wsClient = new Client({ + webSocket = new Client({ brokerURL: `ws://localhost:${apiPort}/ws`, onConnect: () => { - wsClient.subscribe('NEBULOSA.EVENT', message => { + webSocket.subscribe('NEBULOSA.EVENT', message => { const event = JSON.parse(message.body) as MessageEvent if (event.eventName) { @@ -59,7 +59,7 @@ function createMainWindow() { }, }) - wsClient.activate() + webSocket.activate() } function createWindow(options: OpenWindow, parent?: BrowserWindow) { @@ -74,13 +74,13 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { return window } - const size = screen.getPrimaryDisplay().workAreaSize + const screenSize = screen.getPrimaryDisplay().workAreaSize function computeWidth(value: number | string) { if (typeof value === 'number') { return value } else if (value.endsWith('%')) { - return parseFloat(value.substring(0, value.length - 1)) * size.width / 100 + return parseFloat(value.substring(0, value.length - 1)) * screenSize.width / 100 } else { return parseFloat(value) } @@ -92,7 +92,7 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { if (typeof value === 'number') { return value } else if (value.endsWith('%')) { - return parseFloat(value.substring(0, value.length - 1)) * size.height / 100 + return parseFloat(value.substring(0, value.length - 1)) * screenSize.height / 100 } else if (value.endsWith('w')) { return parseFloat(value.substring(0, value.length - 1)) * width } else { @@ -106,11 +106,17 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { const icon = options.icon ?? 'nebulosa' const data = encodeURIComponent(JSON.stringify(options.data || {})) - const position = !options.modal ? store.get(`window.${options.id}.position`, undefined) as { x: number, y: number } | undefined : undefined + const savedPos = !options.modal ? store.get(`window.${options.id}.position`, undefined) as { x: number, y: number } | undefined : undefined + const savedSize = !options.modal && options.resizable ? store.get(`window.${options.id}.size`, undefined) as { width: number, height: number } | undefined : undefined - if (position) { - position.x = Math.max(0, Math.min(position.x, size.width)) - position.y = Math.max(0, Math.min(position.y, size.height)) + if (savedPos) { + savedPos.x = Math.max(0, Math.min(savedPos.x, screenSize.width)) + savedPos.y = Math.max(0, Math.min(savedPos.y, screenSize.height)) + } + + if (savedSize) { + savedSize.width = Math.max(0, Math.min(savedSize.width, screenSize.width)) + savedSize.height = Math.max(0, Math.min(savedSize.height, screenSize.height)) } window = new BrowserWindow({ @@ -118,9 +124,10 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { frame: false, modal: options.modal, parent, - width, height, - x: position?.x ?? undefined, - y: position?.y ?? undefined, + width: savedSize?.width || width, + height: savedSize?.height || height, + x: savedPos?.x ?? undefined, + y: savedPos?.y ?? undefined, resizable: serve || resizable, autoHideMenuBar: true, icon: path.join(__dirname, serve ? `../src/assets/icons/${icon}.png` : `assets/icons/${icon}.png`), @@ -134,7 +141,7 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { }, }) - if (!position) { + if (!savedPos) { window.center() } @@ -161,6 +168,15 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { } }) + if (!serve && window.isResizable()) { + window.on('resized', () => { + if (window) { + const [width, height] = window.getSize() + store.set(`window.${options.id}.size`, { width, height }) + } + }) + } + window.on('close', () => { const homeWindow = browserWindows.get('home') From 3e28ca1501797b694e65379caa235f0e43a0b615 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 24 Feb 2024 03:07:38 -0300 Subject: [PATCH 53/87] [docs]: Add instructions to build this project --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 5b7b05d37..af52ae372 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,19 @@ [![Active Development](https://img.shields.io/badge/Maintenance%20Level-Actively%20Developed-brightgreen.svg)](https://gist.github.com/cheerfulstoic/d107229326a01ff0f333a1d3476e068d) [![CI](https://github.com/tiagohm/nebulosa/actions/workflows/ci.yml/badge.svg)](https://github.com/tiagohm/nebulosa/actions/workflows/ci.yml) [![CodeFactor](https://www.codefactor.io/repository/github/tiagohm/nebulosa/badge/main)](https://www.codefactor.io/repository/github/tiagohm/nebulosa/overview/main) + +The complete integrated solution for all of your astronomical imaging needs. + +## Building + +### Pre-requisites + +* Java 17 +* Node 20.9.0 or newer + +### Steps + +* `./gradlew api:bootJar` +* `cd desktop` +* `npm i` +* `npm run electron:build` From f9699ed13426c24e360b3a82aba903c843c4fd4c Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 24 Feb 2024 10:07:32 -0300 Subject: [PATCH 54/87] [api]: Improve server-side connection close handling --- .../api/connection/ConnectionService.kt | 18 +++++++----------- nebulosa-alpaca-indi/build.gradle.kts | 1 - .../alpaca/indi/client/AlpacaClient.kt | 4 ++++ .../nebulosa/alpaca/indi/device/ASCOMDevice.kt | 3 +-- nebulosa-indi-client/build.gradle.kts | 1 - .../kotlin/nebulosa/indi/client/INDIClient.kt | 3 +-- .../client/device/INDIDeviceProtocolHandler.kt | 4 ++++ nebulosa-indi-connection/build.gradle.kts | 17 ----------------- .../indi/connection/ConnectionClosed.kt | 10 ---------- .../nebulosa/indi/device/DeviceEventHandler.kt | 2 ++ settings.gradle.kts | 1 - 11 files changed, 19 insertions(+), 45 deletions(-) delete mode 100644 nebulosa-indi-connection/build.gradle.kts delete mode 100644 nebulosa-indi-connection/src/main/kotlin/nebulosa/indi/connection/ConnectionClosed.kt diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt index a54f20eb9..8bd268290 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt @@ -4,10 +4,7 @@ import nebulosa.alpaca.indi.client.AlpacaClient import nebulosa.api.messages.MessageService import nebulosa.indi.client.INDIClient import nebulosa.indi.client.connection.INDISocketConnection -import nebulosa.indi.connection.ConnectionClosed import nebulosa.indi.device.Device -import nebulosa.indi.device.DeviceEvent -import nebulosa.indi.device.DeviceEventHandler import nebulosa.indi.device.INDIDeviceProvider import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.filterwheel.FilterWheel @@ -30,7 +27,7 @@ class ConnectionService( private val connectionEventHandler: ConnectionEventHandler, private val alpacaHttpClient: OkHttpClient, private val messageService: MessageService, -) : DeviceEventHandler, Closeable { +) : Closeable { private val providers = LinkedHashMap() @@ -63,7 +60,7 @@ class ConnectionService( val client = INDIClient(host, port) client.registerDeviceEventHandler(eventBus::post) client.registerDeviceEventHandler(connectionEventHandler) - client.registerDeviceEventHandler(this) + client.registerDeviceEventHandler { sendConnectionClosedEvent(client) } client.start() client } @@ -71,6 +68,7 @@ class ConnectionService( val client = AlpacaClient(host, port, alpacaHttpClient) client.registerDeviceEventHandler(eventBus::post) client.registerDeviceEventHandler(connectionEventHandler) + client.registerDeviceEventHandler { sendConnectionClosedEvent(client) } client.discovery() client } @@ -97,12 +95,10 @@ class ConnectionService( providers.clear() } - override fun onEventReceived(event: DeviceEvent<*>) { - if (event is ConnectionClosed) { - LOG.info("client connection was closed. id={}", event.provider.id) - providers.remove(event.provider.id) - messageService.sendMessage(ConnectionClosedWithClient(event.provider.id)) - } + private fun sendConnectionClosedEvent(provider: INDIDeviceProvider) { + LOG.info("client connection was closed. id={}", provider.id) + providers.remove(provider.id) + messageService.sendMessage(ConnectionClosedWithClient(provider.id)) } override fun close() { diff --git a/nebulosa-alpaca-indi/build.gradle.kts b/nebulosa-alpaca-indi/build.gradle.kts index 375d4979b..c2649cd18 100644 --- a/nebulosa-alpaca-indi/build.gradle.kts +++ b/nebulosa-alpaca-indi/build.gradle.kts @@ -5,7 +5,6 @@ plugins { dependencies { api(project(":nebulosa-alpaca-api")) - api(project(":nebulosa-indi-connection")) api(project(":nebulosa-indi-device")) implementation(project(":nebulosa-log")) testImplementation(project(":nebulosa-test")) diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt index 639a1c347..ebe79320c 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt @@ -61,6 +61,10 @@ data class AlpacaClient( handlers.forEach { it.onEventReceived(event) } } + internal fun fireOnConnectionClosed() { + handlers.forEach { it.onConnectionClosed() } + } + override fun cameras(): List { return synchronized(cameras) { cameras.values.toList() } } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt index c1f6200bc..e3af0574c 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt @@ -5,7 +5,6 @@ import nebulosa.alpaca.api.AlpacaResponse import nebulosa.alpaca.api.ConfiguredDevice import nebulosa.alpaca.indi.client.AlpacaClient import nebulosa.common.time.Stopwatch -import nebulosa.indi.connection.ConnectionClosed import nebulosa.indi.device.* import nebulosa.log.loggerFor import retrofit2.Call @@ -96,7 +95,7 @@ abstract class ASCOMDevice : Device { } catch (e: HttpException) { LOG.error("unexpected response. device=$name", e) } catch (e: Throwable) { - sender.fireOnEventReceived(ConnectionClosed(sender)) + sender.fireOnConnectionClosed() LOG.error("unexpected error. device=$name", e) } diff --git a/nebulosa-indi-client/build.gradle.kts b/nebulosa-indi-client/build.gradle.kts index 30afff1d2..523dab9a5 100644 --- a/nebulosa-indi-client/build.gradle.kts +++ b/nebulosa-indi-client/build.gradle.kts @@ -7,7 +7,6 @@ dependencies { api(project(":nebulosa-io")) api(project(":nebulosa-nova")) api(project(":nebulosa-imaging")) - api(project(":nebulosa-indi-connection")) api(project(":nebulosa-indi-device")) implementation(project(":nebulosa-log")) testImplementation(project(":nebulosa-test")) diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt index a7f3e673a..aff8daf11 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt @@ -12,7 +12,6 @@ import nebulosa.indi.client.device.focusers.INDIFocuser import nebulosa.indi.client.device.mounts.INDIMount import nebulosa.indi.client.device.mounts.IoptronV3Mount import nebulosa.indi.client.device.wheels.INDIFilterWheel -import nebulosa.indi.connection.ConnectionClosed import nebulosa.indi.device.Device import nebulosa.indi.device.INDIDeviceProvider import nebulosa.indi.device.camera.Camera @@ -79,7 +78,7 @@ data class INDIClient(val connection: INDIConnection) : INDIDeviceProtocolHandle } override fun onConnectionClosed() { - fireOnEventReceived(ConnectionClosed(this)) + fireOnConnectionClosed() } override fun cameras(): List { diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt index b1b6e0bc7..824c40ee4 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt @@ -73,6 +73,10 @@ abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser, Cl handlers.forEach { it.onEventReceived(event) } } + fun fireOnConnectionClosed() { + handlers.forEach { it.onConnectionClosed() } + } + internal fun registerGPS(device: GPS) { if (device.name !in gps) { gps[device.name] = device diff --git a/nebulosa-indi-connection/build.gradle.kts b/nebulosa-indi-connection/build.gradle.kts deleted file mode 100644 index 46b4907f0..000000000 --- a/nebulosa-indi-connection/build.gradle.kts +++ /dev/null @@ -1,17 +0,0 @@ -plugins { - kotlin("jvm") - id("maven-publish") -} - -dependencies { - api(project(":nebulosa-indi-device")) - testImplementation(project(":nebulosa-test")) -} - -publishing { - publications { - create("pluginMaven") { - from(components["java"]) - } - } -} diff --git a/nebulosa-indi-connection/src/main/kotlin/nebulosa/indi/connection/ConnectionClosed.kt b/nebulosa-indi-connection/src/main/kotlin/nebulosa/indi/connection/ConnectionClosed.kt deleted file mode 100644 index 5613cc68d..000000000 --- a/nebulosa-indi-connection/src/main/kotlin/nebulosa/indi/connection/ConnectionClosed.kt +++ /dev/null @@ -1,10 +0,0 @@ -package nebulosa.indi.connection - -import nebulosa.indi.device.Device -import nebulosa.indi.device.DeviceEvent -import nebulosa.indi.device.INDIDeviceProvider - -data class ConnectionClosed(val provider: INDIDeviceProvider) : DeviceEvent { - - override val device = null -} diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceEventHandler.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceEventHandler.kt index 3300fcd3d..0a6d33df2 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceEventHandler.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/DeviceEventHandler.kt @@ -3,4 +3,6 @@ package nebulosa.indi.device fun interface DeviceEventHandler { fun onEventReceived(event: DeviceEvent<*>) + + fun onConnectionClosed() = Unit } diff --git a/settings.gradle.kts b/settings.gradle.kts index bc7bc14b9..2b677f50a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -63,7 +63,6 @@ include(":nebulosa-hips2fits") include(":nebulosa-horizons") include(":nebulosa-imaging") include(":nebulosa-indi-client") -include(":nebulosa-indi-connection") include(":nebulosa-indi-device") include(":nebulosa-indi-protocol") include(":nebulosa-io") From 8e24c7fbb736236feb1548336beee3ae7e4a337d Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 24 Feb 2024 11:56:44 -0300 Subject: [PATCH 55/87] [desktop]: Fix window auto resize for Sky Atlas --- desktop/app/main.ts | 6 +-- desktop/app/preload.js | 2 +- desktop/sky-atlas.png | Bin 61439 -> 65719 bytes desktop/src/app/app.component.ts | 10 +++-- desktop/src/app/atlas/atlas.component.html | 36 ++++++++++-------- desktop/src/app/atlas/atlas.component.scss | 6 +-- .../camera-exposure.component.html | 2 +- .../shared/services/browser-window.service.ts | 2 +- desktop/src/shared/types/app.types.ts | 1 + desktop/src/styles.scss | 7 +++- desktop/src/typings.d.ts | 14 ++++++- 11 files changed, 56 insertions(+), 30 deletions(-) diff --git a/desktop/app/main.ts b/desktop/app/main.ts index c49e13a96..4561e7691 100644 --- a/desktop/app/main.ts +++ b/desktop/app/main.ts @@ -135,7 +135,7 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { nodeIntegration: true, allowRunningInsecureContent: serve, contextIsolation: false, - additionalArguments: [`--port=${apiPort}`, `--id=${options.id}`, `--modal=${options.modal ?? false}`], + additionalArguments: [`--port=${apiPort}`, `--options=${Buffer.from(JSON.stringify(options)).toString('base64')}`], preload: path.join(__dirname, 'preload.js'), devTools: serve, }, @@ -168,7 +168,7 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { } }) - if (!serve && window.isResizable()) { + if (!serve && window.isResizable() && options.autoResizable !== false) { window.on('resized', () => { if (window) { const [width, height] = window.getSize() @@ -438,7 +438,7 @@ try { const maxHeight = screen.getPrimaryDisplay().workAreaSize.height const height = Math.max(0, Math.min(data, maxHeight)) window.setSize(size[0], height) - console.info('window resized', size[0], height) + console.info('window resized:', size[0], height) return true }) diff --git a/desktop/app/preload.js b/desktop/app/preload.js index 6a80a8492..290851dd0 100644 --- a/desktop/app/preload.js +++ b/desktop/app/preload.js @@ -4,4 +4,4 @@ function argWith(name) { window.apiPort = parseInt(argWith('port')) window.id = argWith('id') -window.modal = argWith('modal') === 'true' +window.options = JSON.parse(Buffer.from(argWith('options'), 'base64').toString('utf-8')) diff --git a/desktop/sky-atlas.png b/desktop/sky-atlas.png index 0e1dbec7046f39179db3716cdbd6b68b8c9d2416..82c34f10159bb8993877a7e802b1694d091a3bfa 100644 GIT binary patch literal 65719 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%0A#^{$LyN^4Bi$|4)wvRtpf`9t~iVXH4x7!o$ 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

lB@zx?0fVx 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%0DvT!|M}o_{GAlc`&`q}aA? zS!82ePD`?zIyTN@R7yVi_VUsmp^wf5(rj6}>1Llj23`AfN?)#*9#P!fU=CN@*jqVV zv1DLqULE~2H>(&$WT(!CBv~?NRi)B}A7_}JUGogP>6AOwHxwO{_o1O>+l`r@lKHpu zhDOMiS5Gl7t*fe$-})JPZ;gMdec)SZa1BDCC1vG1dn&(fGNSt6-#?;iu<4(2GImnc z9KF`U@KobDchiLFUjfHB5ADdflJ)M_G~XB?9_e45B}}>y8McNU`@o) zn4e^G!{SJ!=I=*~YKO8N+Sy!%-HZy>TiT>Nk}_P*n3m3ywW+tXCVS&edaK&%!K34dr(39~bW~IL&&42D|y@_^wp{b!k^7**ysoH3< z#<{np=7Zn%W_j8Sj(rJ-2DB5DScy)erKhuVUJeY@dq;k9Pw3sT$@lRM={!QpZM9j< z+7*9nqPxr+{>c_%1j5QeoNQH2KN8e>dwuZwiRJr2;y)0Iodz^?GbZ< z3P+s$2`b4X@Rc(O)$iPA1yUR`hDV0;I{rU6whQnDrjm^Fdjb0Gr6|z)l> z57_%RrnaR-oj7;Qi#_)LGbH^FO&tHtk?gFL;C}&x8LT@1 literal 61439 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 Lo==m#_2|C this.electron.autoResizeWindow(), 1000) + if (window.options.autoResizable !== false) { + setTimeout(() => this.electron.autoResizeWindow(), 1000) + } } pin() { diff --git a/desktop/src/app/atlas/atlas.component.html b/desktop/src/app/atlas/atlas.component.html index 946a940c6..27d9cbf85 100644 --- a/desktop/src/app/atlas/atlas.component.html +++ b/desktop/src/app/atlas/atlas.component.html @@ -7,9 +7,11 @@

-
- - SDO/HMI + @@ -18,8 +20,8 @@
-
- +
+
@@ -96,8 +98,10 @@ - - +
+ + +
@@ -136,10 +140,12 @@ - - +
+ + +
@@ -267,13 +273,13 @@
-
- +
+ + {{ name ?? '' }} -
diff --git a/desktop/src/app/atlas/atlas.component.scss b/desktop/src/app/atlas/atlas.component.scss index 83e56ac40..d304e8880 100644 --- a/desktop/src/app/atlas/atlas.component.scss +++ b/desktop/src/app/atlas/atlas.component.scss @@ -9,16 +9,16 @@ padding-right: 0.21rem; p-table.planet .p-datatable-wrapper { - height: calc(100vh - 301px); + height: 229px; } p-table.minorPlanet .p-datatable-wrapper { - height: calc(100vh - 343px); + height: 189px; } p-table.skyObject .p-datatable-wrapper, p-table.satellite .p-datatable-wrapper { - height: calc(100vh - 389px); + height: 143px; } .p-tabview-nav li { diff --git a/desktop/src/shared/components/camera-exposure/camera-exposure.component.html b/desktop/src/shared/components/camera-exposure/camera-exposure.component.html index 325e10553..0b4fc1559 100644 --- a/desktop/src/shared/components/camera-exposure/camera-exposure.component.html +++ b/desktop/src/shared/components/camera-exposure/camera-exposure.component.html @@ -3,7 +3,7 @@ {{ (state ?? 'IDLE') | enum | lowercase }} - + {{ exposure.count }} / {{ capture.amount }} diff --git a/desktop/src/shared/services/browser-window.service.ts b/desktop/src/shared/services/browser-window.service.ts index b11f6aaa8..afe3f125e 100644 --- a/desktop/src/shared/services/browser-window.service.ts +++ b/desktop/src/shared/services/browser-window.service.ts @@ -79,7 +79,7 @@ export class BrowserWindowService { } openSkyAtlas(options: OpenWindowOptionsWithData) { - Object.assign(options, { icon: 'atlas', width: 450, height: 565 }) + Object.assign(options, { icon: 'atlas', width: 450, height: 530, autoResizable: false }) this.openWindow({ ...options, id: 'atlas', path: 'atlas' }) } diff --git a/desktop/src/shared/types/app.types.ts b/desktop/src/shared/types/app.types.ts index 7eec29375..59dff8d69 100644 --- a/desktop/src/shared/types/app.types.ts +++ b/desktop/src/shared/types/app.types.ts @@ -45,6 +45,7 @@ export interface OpenWindow extends OpenWindowOptionsWithData { id: string path: string modal?: boolean + autoResizable?: boolean } export interface CloseWindow { diff --git a/desktop/src/styles.scss b/desktop/src/styles.scss index 860032632..1a9358367 100644 --- a/desktop/src/styles.scss +++ b/desktop/src/styles.scss @@ -151,7 +151,8 @@ p-calendar.border-0 .p-calendar-w-btn { } .p-tag { - padding: 0.13rem 0.4rem; + padding: 0.1rem 0.4rem; + border-radius: 2px; .p-tag-value { line-height: 1.2 !important; @@ -250,6 +251,10 @@ p-dropdownitem *, font-family: monospace !important; } +.gap-1px { + gap: 1px; +} + ::-webkit-scrollbar { width: 6px; } diff --git a/desktop/src/typings.d.ts b/desktop/src/typings.d.ts index 271e0b75c..5ac88f709 100644 --- a/desktop/src/typings.d.ts +++ b/desktop/src/typings.d.ts @@ -8,5 +8,17 @@ interface Window { process: any require: any apiPort: number - modal: boolean + options: { + icon?: string + resizable?: boolean + width?: number | string + height?: number | string + bringToFront?: boolean + requestFocus?: boolean + id: string + path: string + modal?: boolean + autoResizable?: boolean + data: any + } } From 49c1375b5faab77a5686f77f0adc56774ce3d353 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sat, 24 Feb 2024 13:01:21 -0300 Subject: [PATCH 56/87] [desktop]: Fix somes ExpressionChangedAfterItHasBeenCheckedError errors --- desktop/src/app/alignment/alignment.component.ts | 3 ++- desktop/src/app/framing/framing.component.html | 9 +++++---- desktop/src/app/framing/framing.component.ts | 4 ++-- desktop/src/app/image/image.component.ts | 4 ++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/desktop/src/app/alignment/alignment.component.ts b/desktop/src/app/alignment/alignment.component.ts index 89c566d04..d0cad9edd 100644 --- a/desktop/src/app/alignment/alignment.component.ts +++ b/desktop/src/app/alignment/alignment.component.ts @@ -219,13 +219,14 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { }) } }) + + this.loadPreference() } async ngAfterViewInit() { this.cameras = (await this.api.cameras()).sort(deviceComparator) this.mounts = (await this.api.mounts()).sort(deviceComparator) this.guideOutputs = (await this.api.guideOutputs()).sort(deviceComparator) - this.loadPreference() } @HostListener('window:unload') diff --git a/desktop/src/app/framing/framing.component.html b/desktop/src/app/framing/framing.component.html index 207a67842..b20eaecf9 100644 --- a/desktop/src/app/framing/framing.component.html +++ b/desktop/src/app/framing/framing.component.html @@ -41,11 +41,12 @@
@@ -248,4 +248,6 @@ -
\ No newline at end of file +