From 40afe51f82c8fda0d36ceafc6c180bffea791ee9 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Mon, 29 Apr 2024 09:10:42 -0300 Subject: [PATCH 1/6] [api]: Implement Siril live stacking --- .../nebulosa/api/livestacking/LiveStacker.kt | 12 + .../api/livestacking/LiveStackerType.kt | 5 + .../api/livestacking/SirilLiveStacker.kt | 76 +++++++ api/src/test/kotlin/SirilLiveStackerTest.kt | 28 +++ .../astap/plate/solving/AstapPlateSolver.kt | 44 ++-- .../astap/star/detection/AstapStarDetector.kt | 25 ++- .../solving/LocalAstrometryNetPlateSolver.kt | 166 +++++++------- .../nebulosa/common/exec/CommandLine.kt | 210 ++++++++++++++++++ .../nebulosa/common/exec/CommandLineDSL.kt | 5 + .../nebulosa/common/exec/LineReadListener.kt | 8 + .../common/process/ProcessExecutor.kt | 46 ---- .../src/test/kotlin/CommandLineTest.kt | 76 +++++++ 12 files changed, 543 insertions(+), 158 deletions(-) create mode 100644 api/src/main/kotlin/nebulosa/api/livestacking/LiveStacker.kt create mode 100644 api/src/main/kotlin/nebulosa/api/livestacking/LiveStackerType.kt create mode 100644 api/src/main/kotlin/nebulosa/api/livestacking/SirilLiveStacker.kt create mode 100644 api/src/test/kotlin/SirilLiveStackerTest.kt create mode 100644 nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLine.kt create mode 100644 nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLineDSL.kt create mode 100644 nebulosa-common/src/main/kotlin/nebulosa/common/exec/LineReadListener.kt delete mode 100644 nebulosa-common/src/main/kotlin/nebulosa/common/process/ProcessExecutor.kt create mode 100644 nebulosa-common/src/test/kotlin/CommandLineTest.kt diff --git a/api/src/main/kotlin/nebulosa/api/livestacking/LiveStacker.kt b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStacker.kt new file mode 100644 index 000000000..7f5fed47d --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStacker.kt @@ -0,0 +1,12 @@ +package nebulosa.api.livestacking + +import java.nio.file.Path + +interface LiveStacker { + + fun start() + + fun add(path: Path): Path + + fun stop() +} diff --git a/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackerType.kt b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackerType.kt new file mode 100644 index 000000000..5e1d6426e --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackerType.kt @@ -0,0 +1,5 @@ +package nebulosa.api.livestacking + +enum class LiveStackerType { + SIRIL, +} diff --git a/api/src/main/kotlin/nebulosa/api/livestacking/SirilLiveStacker.kt b/api/src/main/kotlin/nebulosa/api/livestacking/SirilLiveStacker.kt new file mode 100644 index 000000000..cb0e5fac2 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/livestacking/SirilLiveStacker.kt @@ -0,0 +1,76 @@ +package nebulosa.api.livestacking + +import nebulosa.common.exec.CommandLine +import nebulosa.common.exec.LineReadListener +import nebulosa.common.exec.commandLine +import nebulosa.math.Angle +import java.nio.file.Path + +data class SirilLiveStacker( + private val executablePath: Path, + private val workingDirectory: Path, + private val dark: Path? = null, + private val flat: Path? = null, + private val rotate: Angle = 0.0, + private val use32Bits: Boolean = false, +) : LiveStacker, LineReadListener { + + @Volatile private var process: CommandLine? = null + + @Synchronized + override fun start() { + if (process == null) { + process = commandLine { + executablePath(executablePath) + putArg("-s", "-") + registerLineReadListener(this@SirilLiveStacker) + } + + process!!.whenComplete { _, e -> + println("completed. $e") + process = null + } + + process!!.start() + + process!!.writer.println(REQUIRES_COMMAND) + process!!.writer.println("$CD_COMMAND $workingDirectory") + process!!.writer.println(buildString(256) { + append(START_LS_COMMAND) + if (dark != null) append(" \"-dark=$dark\"") + if (flat != null) append(" \"-flat=$flat\"") + if (rotate != 0.0) append(" -rotate=$rotate") + if (use32Bits) append(" -32bits") + }) + } + } + + @Synchronized + override fun add(path: Path): Path { + process?.writer?.println("$LS_COMMAND $path") + return path + } + + @Synchronized + override fun stop() { + process?.writer?.println(STOP_LS_COMMAND) + process = null + } + + override fun onInputRead(line: String) { + println(line) + } + + override fun onErrorRead(line: String) { + println(line) + } + + companion object { + + private const val REQUIRES_COMMAND = "requires 1.0.0" + private const val CD_COMMAND = "cd" + private const val START_LS_COMMAND = "start_ls" + private const val LS_COMMAND = "livestack" + private const val STOP_LS_COMMAND = "stop_ls" + } +} diff --git a/api/src/test/kotlin/SirilLiveStackerTest.kt b/api/src/test/kotlin/SirilLiveStackerTest.kt new file mode 100644 index 000000000..24422f6de --- /dev/null +++ b/api/src/test/kotlin/SirilLiveStackerTest.kt @@ -0,0 +1,28 @@ +import io.kotest.core.annotation.EnabledIf +import io.kotest.core.spec.style.StringSpec +import nebulosa.api.livestacking.SirilLiveStacker +import nebulosa.test.NonGitHubOnlyCondition +import java.nio.file.Path +import kotlin.io.path.listDirectoryEntries + +@EnabledIf(NonGitHubOnlyCondition::class) +class SirilLiveStackerTest : StringSpec() { + + init { + "live stacking" { + val executable = Path.of("siril-cli") + val workingDir = Path.of("/home/tiagohm/Git/nebulosa/data") + val siril = SirilLiveStacker(executable, workingDir) + siril.start() + + val fitsDir = Path.of("/home/tiagohm/Imagens/Astrophotos/Light/NGC2070/2024-04-20") + + for (fits in fitsDir.listDirectoryEntries()) { + siril.add(fits) + Thread.sleep(1000) + } + + siril.stop() + } + } +} 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 4edb44f8a..30804dc87 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,7 +1,7 @@ package nebulosa.astap.plate.solving import nebulosa.common.concurrency.cancel.CancellationToken -import nebulosa.common.process.ProcessExecutor +import nebulosa.common.exec.commandLine import nebulosa.fits.FitsHeader import nebulosa.fits.FitsKeyword import nebulosa.image.Image @@ -25,9 +25,7 @@ import kotlin.math.ceil /** * @see README */ -class AstapPlateSolver(path: Path) : PlateSolver { - - private val executor = ProcessExecutor(path) +data class AstapPlateSolver(private val executablePath: Path) : PlateSolver { override fun solve( path: Path?, image: Image?, @@ -37,34 +35,37 @@ class AstapPlateSolver(path: Path) : PlateSolver { ): PlateSolution { requireNotNull(path) { "path is required" } - val arguments = mutableMapOf() - val basePath = Files.createTempDirectory("astap") val baseName = UUID.randomUUID().toString() val outFile = Paths.get("$basePath", baseName) - arguments["-o"] = outFile - arguments["-z"] = downsampleFactor - arguments["-fov"] = 0 // auto + val cmd = commandLine { + executablePath(executablePath) + workingDirectory(path.parent) - if (radius.toDegrees >= 0.1 && centerRA.isFinite() && centerDEC.isFinite()) { - arguments["-ra"] = centerRA.toHours - arguments["-spd"] = centerDEC.toDegrees + 90.0 - arguments["-r"] = ceil(radius.toDegrees) - } else { - arguments["-r"] = "180.0" - } + putArg("-o", outFile) + putArg("-z", downsampleFactor) + putArg("-fov", "0") // auto - arguments["-f"] = path + if (radius.toDegrees >= 0.1 && centerRA.isFinite() && centerDEC.isFinite()) { + putArg("-ra", centerRA.toHours) + putArg("-spd", centerDEC.toDegrees + 90.0) + putArg("-r", ceil(radius.toDegrees)) + } else { + putArg("-r", "180.0") + } + + putArg("-f", path) + } - LOG.info("ASTAP solving. command={}", arguments) + LOG.info("ASTAP solving. command={}", cmd.command) try { val timeoutOrDefault = timeout?.takeIf { it.toSeconds() > 0 } ?: Duration.ofMinutes(5) - val process = executor.execute(arguments, timeoutOrDefault, path.parent, cancellationToken) + cancellationToken.listen(cmd) + cmd.start(timeoutOrDefault) - if (process.isAlive) process.destroyForcibly() - LOG.info("astap exited. code={}", process.exitValue()) + LOG.info("astap exited. code={}", cmd.get()) if (cancellationToken.isCancelled) return PlateSolution.NO_SOLUTION @@ -121,6 +122,7 @@ class AstapPlateSolver(path: Path) : PlateSolver { throw PlateSolvingException(message) } } finally { + cancellationToken.unlisten(cmd) basePath.deleteRecursively() } } diff --git a/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt b/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt index 87306d63d..6e9638851 100644 --- a/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt +++ b/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt @@ -2,7 +2,7 @@ package nebulosa.astap.star.detection import de.siegmar.fastcsv.reader.CommentStrategy import de.siegmar.fastcsv.reader.CsvReader -import nebulosa.common.process.ProcessExecutor +import nebulosa.common.exec.commandLine import nebulosa.log.loggerFor import nebulosa.star.detection.ImageStar import nebulosa.star.detection.StarDetector @@ -13,20 +13,25 @@ import kotlin.io.path.exists import kotlin.io.path.inputStream import kotlin.io.path.nameWithoutExtension -class AstapStarDetector(path: Path) : StarDetector { - - private val executor = ProcessExecutor(path) +data class AstapStarDetector(private val executablePath: Path) : StarDetector { override fun detect(input: Path): List { - val arguments = mutableMapOf() + val cmd = commandLine { + executablePath(executablePath) + workingDirectory(input.parent) - arguments["-f"] = input - arguments["-z"] = 2 - arguments["-extract"] = 0 + putArg("-f", input) + putArg("-z", "2") + putArg("-extract", "0") + } - val process = executor.execute(arguments, workingDir = input.parent) + try { + cmd.start() - LOG.info("astap exited. code={}", process.exitValue()) + LOG.info("astap exited. code={}", cmd.get()) + } catch (e: Throwable) { + return emptyList() + } val csvFile = Path.of("${input.parent}", input.nameWithoutExtension + ".csv") 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 690922646..e7e6ab19c 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,7 +1,8 @@ package nebulosa.astrometrynet.plate.solving import nebulosa.common.concurrency.cancel.CancellationToken -import nebulosa.common.process.ProcessExecutor +import nebulosa.common.exec.LineReadListener +import nebulosa.common.exec.commandLine import nebulosa.image.Image import nebulosa.log.loggerFor import nebulosa.math.* @@ -11,15 +12,13 @@ import java.nio.file.Files import java.nio.file.Path import java.time.Duration import java.util.* -import kotlin.concurrent.thread +import java.util.function.Supplier import kotlin.io.path.deleteRecursively /** * @see README */ -class LocalAstrometryNetPlateSolver(path: Path) : PlateSolver { - - private val executor = ProcessExecutor(path) +data class LocalAstrometryNetPlateSolver(private val executablePath: Path) : PlateSolver { override fun solve( path: Path?, image: Image?, @@ -29,105 +28,110 @@ class LocalAstrometryNetPlateSolver(path: Path) : PlateSolver { ): PlateSolution { requireNotNull(path) { "path is required" } - val arguments = mutableMapOf() - - arguments["--out"] = UUID.randomUUID().toString() - arguments["--overwrite"] = null - val outFolder = Files.createTempDirectory("localplatesolver") - arguments["--dir"] = outFolder - - arguments["--cpulimit"] = timeout?.takeIf { it.toSeconds() > 0 }?.toSeconds() ?: 300 - arguments["--scale-units"] = "degwidth" - arguments["--guess-scale"] = null - arguments["--crpix-center"] = null - arguments["--downsample"] = downsampleFactor - arguments["--no-verify"] = null - arguments["--no-plots"] = null - // args["--resort"] = null - - if (radius.toDegrees >= 0.1 && centerRA.isFinite() && centerDEC.isFinite()) { - arguments["--ra"] = centerRA.toDegrees - arguments["--dec"] = centerDEC.toDegrees - arguments["--radius"] = radius.toDegrees - } - arguments["$path"] = null + val cmd = commandLine { + executablePath(executablePath) + workingDirectory(path.parent) - val process = executor.execute(arguments, Duration.ZERO, path.parent, cancellationToken) + putArg("--out", UUID.randomUUID().toString()) + putArg("--overwrite") - val buffer = process.inputReader() + putArg("--dir", outFolder) - var solution = PlateSolution(false, 0.0, 0.0, 0.0, 0.0) + putArg("--cpulimit", timeout?.takeIf { it.toSeconds() > 0 }?.toSeconds() ?: 300) + putArg("--scale-units", "degwidth") + putArg("--guess-scale") + putArg("--crpix-center") + putArg("--downsample", downsampleFactor) + putArg("--no-verify") + putArg("--no-plots") + // putArg("--resort") - val parseThread = thread { - for (line in buffer.lines()) { - solution = solution - .parseFieldCenter(line) - .parseFieldRotation(line) - .parsePixelScale(line) - .parseFieldSize(line) + if (radius.toDegrees >= 0.1 && centerRA.isFinite() && centerDEC.isFinite()) { + putArg("--ra", centerRA.toDegrees) + putArg("--dec", centerDEC.toDegrees) + putArg("--radius", radius.toDegrees) } - // Populate WCS headers from calibration info. - // TODO: calibration = calibration.copy() - // TODO: Mark calibration as solved. - - LOG.info("astrometry.net solved. calibration={}", solution) + putArg("$path") } + val solution = PlateSolutionLineReader() + try { - process.waitFor() - LOG.info("astrometry.net exited. code={}", process.exitValue()) - } catch (e: InterruptedException) { - parseThread.interrupt() - process.destroyForcibly() + cancellationToken.listen(cmd) + cmd.registerLineReadListener(solution) + cmd.start() + LOG.info("astrometry.net exited. code={}", cmd.get()) + return solution.get() + } catch (e: Throwable) { + LOG.error("astronomy.net failed.", e) + return PlateSolution.NO_SOLUTION } finally { + cancellationToken.unlisten(cmd) outFolder.deleteRecursively() } - - return solution } - companion object { + private class PlateSolutionLineReader : LineReadListener, Supplier { - private const val NUMBER_REGEX = "([\\d.+-]+)" + @Volatile private var fieldCenter: DoubleArray? = null + @Volatile private var fieldRotation: Angle = 0.0 + @Volatile private var pixelScale: Angle = 0.0 + @Volatile private var fieldSize: DoubleArray? = null - @JvmStatic private val LOG = loggerFor() - @JvmStatic private val FIELD_CENTER_REGEX = Regex(".*Field center: \\(RA,Dec\\) = \\($NUMBER_REGEX, $NUMBER_REGEX\\).*") - @JvmStatic private val FIELD_SIZE_REGEX = Regex(".*Field size: $NUMBER_REGEX x $NUMBER_REGEX arcminutes.*") - @JvmStatic private val FIELD_ROTATION_REGEX = Regex(".*Field rotation angle: up is $NUMBER_REGEX degrees.*") - @JvmStatic private val PIXEL_SCALE_REGEX = Regex(".*pixel scale $NUMBER_REGEX arcsec/pix.*") - - @JvmStatic - private fun PlateSolution.parseFieldCenter(line: String): PlateSolution { - return FIELD_CENTER_REGEX.matchEntire(line) - ?.let { copy(rightAscension = it.groupValues[1].toDouble().deg, declination = it.groupValues[2].toDouble().deg) } - ?: this + override fun onInputRead(line: String) { + fieldCenter(line)?.also { fieldCenter = it } + ?: fieldRotation(line)?.also { fieldRotation = it } + ?: pixelScale(line)?.also { pixelScale = it } + ?: fieldSize(line)?.also { fieldSize = it } } - @JvmStatic - private fun PlateSolution.parseFieldSize(line: String): PlateSolution { - return FIELD_SIZE_REGEX.matchEntire(line) - ?.let { - val width = it.groupValues[1].toDouble().arcmin - val height = it.groupValues[2].toDouble().arcmin - copy(width = width, height = height) - } ?: this - } + override fun get(): PlateSolution { + val (rightAscension, declination) = fieldCenter!! + val (width, height) = fieldSize!! - @JvmStatic - private fun PlateSolution.parseFieldRotation(line: String): PlateSolution { - return FIELD_ROTATION_REGEX.matchEntire(line) - ?.let { copy(orientation = it.groupValues[1].toDouble().deg) } - ?: this + return PlateSolution(true, fieldRotation, pixelScale, rightAscension, declination, width, height) } - @JvmStatic - private fun PlateSolution.parsePixelScale(line: String): PlateSolution { - return PIXEL_SCALE_REGEX.matchEntire(line) - ?.let { copy(scale = it.groupValues[1].toDouble().arcsec) } - ?: this + companion object { + + private const val NUMBER_REGEX = "([\\d.+-]+)" + + @JvmStatic private val FIELD_CENTER_REGEX = Regex(".*Field center: \\(RA,Dec\\) = \\($NUMBER_REGEX, $NUMBER_REGEX\\).*") + @JvmStatic private val FIELD_SIZE_REGEX = Regex(".*Field size: $NUMBER_REGEX x $NUMBER_REGEX arcminutes.*") + @JvmStatic private val FIELD_ROTATION_REGEX = Regex(".*Field rotation angle: up is $NUMBER_REGEX degrees.*") + @JvmStatic private val PIXEL_SCALE_REGEX = Regex(".*pixel scale $NUMBER_REGEX arcsec/pix.*") + + @JvmStatic + private fun fieldCenter(line: String): DoubleArray? { + return FIELD_CENTER_REGEX.matchEntire(line) + ?.let { doubleArrayOf(it.groupValues[1].toDouble().deg, it.groupValues[2].toDouble().deg) } + } + + @JvmStatic + private fun fieldSize(line: String): DoubleArray? { + return FIELD_SIZE_REGEX.matchEntire(line) + ?.let { doubleArrayOf(it.groupValues[1].toDouble().arcmin, it.groupValues[2].toDouble().arcmin) } + } + + @JvmStatic + private fun fieldRotation(line: String): Angle? { + return FIELD_ROTATION_REGEX.matchEntire(line) + ?.let { it.groupValues[1].toDouble().deg } + } + + @JvmStatic + private fun pixelScale(line: String): Angle? { + return PIXEL_SCALE_REGEX.matchEntire(line) + ?.let { it.groupValues[1].toDouble().arcsec } + } } } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } } diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLine.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLine.kt new file mode 100644 index 000000000..8fac00c71 --- /dev/null +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLine.kt @@ -0,0 +1,210 @@ +package nebulosa.common.exec + +import nebulosa.common.concurrency.cancel.CancellationListener +import nebulosa.common.concurrency.cancel.CancellationSource +import java.io.InputStream +import java.io.OutputStream +import java.io.PrintStream +import java.nio.file.Path +import java.time.Duration +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.function.Supplier + +data class CommandLine internal constructor( + private val builder: ProcessBuilder, + private val listeners: HashSet, +) : CompletableFuture(), CancellationListener { + + @Volatile private var process: Process? = null + @Volatile private var waiter: ProcessWaiter? = null + @Volatile private var inputReader: StreamLineReader? = null + @Volatile private var errorReader: StreamLineReader? = null + + val command: List + get() = builder.command() + + val writer = PrintStream(object : OutputStream() { + + override fun write(b: Int) { + process?.outputStream?.write(b) + } + + override fun write(b: ByteArray) { + process?.outputStream?.write(b) + } + + override fun write(b: ByteArray, off: Int, len: Int) { + process?.outputStream?.write(b, off, len) + } + + override fun flush() { + process?.outputStream?.flush() + } + + override fun close() { + process?.outputStream?.close() + } + }, true) + + fun registerLineReadListener(listener: LineReadListener) { + listeners.add(listener) + } + + fun unregisterLineReadListener(listener: LineReadListener) { + listeners.remove(listener) + } + + @Synchronized + fun start(timeout: Duration = Duration.ZERO) { + if (process == null) { + process = builder.start() + + if (listeners.isNotEmpty()) { + inputReader = StreamLineReader(process!!.inputStream, false) + inputReader!!.start() + + errorReader = StreamLineReader(process!!.errorStream, true) + errorReader!!.start() + } + + waiter = ProcessWaiter(process!!, timeout.toMillis()) + waiter!!.start() + } + } + + @Synchronized + fun stop() { + process?.destroyForcibly() + process = null + + waiter?.interrupt() + waiter = null + + inputReader?.interrupt() + inputReader = null + + errorReader?.interrupt() + errorReader = null + } + + override fun accept(source: CancellationSource) { + stop() + } + + private inner class ProcessWaiter( + private val process: Process, + private val timeout: Long, + ) : Thread() { + + init { + isDaemon = false + } + + override fun run() { + try { + if (timeout > 0L) { + process.waitFor(timeout, TimeUnit.MILLISECONDS) + } else { + process.waitFor() + } + + inputReader?.waitFor() + errorReader?.waitFor() + } catch (ignored: InterruptedException) { + } finally { + if (process.isAlive) { + process.destroyForcibly() + process.waitFor() + } + + complete(process.exitValue()) + } + } + } + + private inner class StreamLineReader( + stream: InputStream, + private val isError: Boolean, + ) : Thread() { + + private val reader = stream.bufferedReader() + private val completable = CompletableFuture() + + init { + isDaemon = false + } + + override fun run() { + try { + while (true) { + val line = reader.readLine() ?: break + if (isError) listeners.forEach { it.onErrorRead(line) } + else listeners.forEach { it.onInputRead(line) } + } + } catch (ignored: Throwable) { + } finally { + completable.complete(Unit) + reader.close() + } + } + + fun waitFor() { + return completable.join() + } + } + + class Builder : Supplier { + + private val builder = ProcessBuilder() + private val environment by lazy { builder.environment() } + private val arguments = mutableMapOf() + private var executable = "" + private val listeners = HashSet(1) + + fun executablePath(path: Path) = executable("$path") + + fun executable(executable: String) = run { this.executable = executable } + + fun env(key: String) = environment[key] + + fun putEnv(key: String, value: String) = environment.put(key, value) + + fun removeEnv(key: String) = environment.remove(key) + + fun hasEnv(key: String) = key in environment + + fun arg(name: String) = arguments[name] + + fun putArg(name: String, value: Any) = arguments.put(name, value) + + fun putArg(name: String) = arguments.put(name, null) + + fun removeArg(name: String) = arguments.remove(name) + + fun hasArg(name: String) = name in arguments + + fun workingDirectory(path: Path): Unit = run { builder.directory(path.toFile()) } + + fun registerLineReadListener(listener: LineReadListener) = listeners.add(listener) + + fun unregisterLineReadListener(listener: LineReadListener) = listeners.remove(listener) + + override fun get(): CommandLine { + val args = ArrayList(1 + arguments.size * 2) + + require(executable.isNotBlank()) { "executable must not be blank" } + + args.add(executable) + + for ((key, value) in arguments) { + args.add(key) + value?.toString()?.also(args::add) + } + + builder.command(args) + + return CommandLine(builder, listeners) + } + } +} diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLineDSL.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLineDSL.kt new file mode 100644 index 000000000..cbe636ef9 --- /dev/null +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLineDSL.kt @@ -0,0 +1,5 @@ +package nebulosa.common.exec + +inline fun commandLine(action: CommandLine.Builder.() -> Unit): CommandLine { + return CommandLine.Builder().also(action).get() +} diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/exec/LineReadListener.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/exec/LineReadListener.kt new file mode 100644 index 000000000..bdc537dfc --- /dev/null +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/exec/LineReadListener.kt @@ -0,0 +1,8 @@ +package nebulosa.common.exec + +interface LineReadListener { + + fun onInputRead(line: String) = Unit + + fun onErrorRead(line: String) = Unit +} diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/process/ProcessExecutor.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/process/ProcessExecutor.kt deleted file mode 100644 index ec0e8d71c..000000000 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/process/ProcessExecutor.kt +++ /dev/null @@ -1,46 +0,0 @@ -package nebulosa.common.process - -import nebulosa.common.concurrency.cancel.CancellationToken -import nebulosa.log.loggerFor -import java.nio.file.Path -import java.time.Duration -import java.util.concurrent.TimeUnit - -open class ProcessExecutor(private val path: Path) { - - fun execute( - arguments: Map, - timeout: Duration? = null, - workingDir: Path? = null, - cancellationToken: CancellationToken = CancellationToken.NONE, - ): Process { - val args = ArrayList(arguments.size * 2) - - for ((key, value) in arguments) { - args.add(key) - if (value != null) args.add("$value") - } - - args.add(0, "$path") - - val process = ProcessBuilder(args) - .also { if (workingDir != null) it.directory(workingDir.toFile()) } - .start()!! - - LOG.info("executing process. pid={}, args={}", process.pid(), args) - - // TODO: READ OUTPUT STREAM LINE TO CALLBACK - - cancellationToken.listen { process.destroyForcibly() } - - if (timeout == null || timeout.isNegative) process.waitFor() - else process.waitFor(timeout.seconds, TimeUnit.SECONDS) - - return process - } - - companion object { - - @JvmStatic private val LOG = loggerFor() - } -} diff --git a/nebulosa-common/src/test/kotlin/CommandLineTest.kt b/nebulosa-common/src/test/kotlin/CommandLineTest.kt new file mode 100644 index 000000000..2887a47a6 --- /dev/null +++ b/nebulosa-common/src/test/kotlin/CommandLineTest.kt @@ -0,0 +1,76 @@ +import io.kotest.core.annotation.EnabledIf +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.ints.shouldBeExactly +import io.kotest.matchers.ints.shouldNotBeExactly +import io.kotest.matchers.longs.shouldBeGreaterThanOrEqual +import io.kotest.matchers.longs.shouldBeLessThan +import nebulosa.common.exec.LineReadListener +import nebulosa.common.exec.commandLine +import nebulosa.test.NonGitHubOnlyCondition +import java.nio.file.Path +import java.time.Duration +import kotlin.concurrent.thread +import kotlin.system.measureTimeMillis + +@EnabledIf(NonGitHubOnlyCondition::class) +class CommandLineTest : StringSpec() { + + init { + "sleep" { + val cmd = commandLine { + executable("sleep") + putArg("2") + } + + measureTimeMillis { + cmd.start() + cmd.get() shouldBeExactly 0 + } shouldBeGreaterThanOrEqual 2000 + } + "sleep with timeout" { + val cmd = commandLine { + executable("sleep") + putArg("10") + } + + measureTimeMillis { + cmd.start(Duration.ofSeconds(2)) + cmd.get() shouldNotBeExactly 0 + } shouldBeGreaterThanOrEqual 2000 + } + "kill sleep" { + val cmd = commandLine { + executable("sleep") + putArg("10") + } + + thread { Thread.sleep(2000); cmd.stop() } + + measureTimeMillis { + cmd.start() + cmd.get() shouldNotBeExactly 0 + } shouldBeGreaterThanOrEqual 2000 shouldBeLessThan 10000 + } + "ls" { + val lineReadListener = object : LineReadListener, ArrayList() { + + override fun onInputRead(line: String) { + add(line) + } + } + + val cmd = commandLine { + executable("ls") + workingDirectory(Path.of("../")) + registerLineReadListener(lineReadListener) + } + + cmd.start() + cmd.get() shouldBeExactly 0 + lineReadListener.shouldNotBeEmpty() + lineReadListener.shouldContain("nebulosa-image") + } + } +} From d934ac5e80ddc5362eb972abaf513a5df9ddaf78 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sun, 2 Jun 2024 10:39:17 -0300 Subject: [PATCH 2/6] [api]: Implement Siril Live Stacking --- api/build.gradle.kts | 1 + .../angle/DeclinationDeserializer.kt | 2 +- .../converters/angle/DegreesDeserializer.kt | 3 + .../nebulosa/api/livestacking/LiveStacker.kt | 12 -- .../livestacking/LiveStackingController.kt | 27 ++++ .../api/livestacking/LiveStackingOptions.kt | 28 ++++ .../api/livestacking/LiveStackingService.kt | 29 ++++ .../api/livestacking/SirilLiveStacker.kt | 76 ---------- api/src/test/kotlin/SirilLiveStackerTest.kt | 14 +- data/.gitignore | 1 + .../astap/star/detection/AstapStarDetector.kt | 30 ++-- .../solving/LocalAstrometryNetPlateSolver.kt | 2 +- .../nebulosa/common/exec/CommandLine.kt | 14 +- .../nebulosa/common/exec/CommandLineDSL.kt | 5 - .../nebulosa/common/exec/LineReadListener.kt | 14 +- .../src/test/kotlin/CommandLineTest.kt | 14 +- nebulosa-livestacking/build.gradle.kts | 16 ++ .../nebulosa/livestacking/LiveStacker.kt | 17 +++ nebulosa-siril/build.gradle.kts | 19 +++ .../siril/livestacking/SirilLiveStacker.kt | 142 ++++++++++++++++++ settings.gradle.kts | 2 + 21 files changed, 339 insertions(+), 129 deletions(-) create mode 100644 api/src/main/kotlin/nebulosa/api/beans/converters/angle/DegreesDeserializer.kt delete mode 100644 api/src/main/kotlin/nebulosa/api/livestacking/LiveStacker.kt create mode 100644 api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingController.kt create mode 100644 api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingOptions.kt create mode 100644 api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingService.kt delete mode 100644 api/src/main/kotlin/nebulosa/api/livestacking/SirilLiveStacker.kt delete mode 100644 nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLineDSL.kt create mode 100644 nebulosa-livestacking/build.gradle.kts create mode 100644 nebulosa-livestacking/src/main/kotlin/nebulosa/livestacking/LiveStacker.kt create mode 100644 nebulosa-siril/build.gradle.kts create mode 100644 nebulosa-siril/src/main/kotlin/nebulosa/siril/livestacking/SirilLiveStacker.kt diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 88cfc8c51..47b11a299 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(project(":nebulosa-nova")) implementation(project(":nebulosa-sbd")) implementation(project(":nebulosa-simbad")) + implementation(project(":nebulosa-siril")) implementation(project(":nebulosa-stellarium-protocol")) implementation(project(":nebulosa-wcs")) implementation(project(":nebulosa-xisf")) diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/angle/DeclinationDeserializer.kt b/api/src/main/kotlin/nebulosa/api/beans/converters/angle/DeclinationDeserializer.kt index 01e170cfe..0ed5dcbd1 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/angle/DeclinationDeserializer.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/converters/angle/DeclinationDeserializer.kt @@ -1,3 +1,3 @@ package nebulosa.api.beans.converters.angle -class DeclinationDeserializer : AngleDeserializer(true) +class DeclinationDeserializer : AngleDeserializer(false) diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/angle/DegreesDeserializer.kt b/api/src/main/kotlin/nebulosa/api/beans/converters/angle/DegreesDeserializer.kt new file mode 100644 index 000000000..b97011eec --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/beans/converters/angle/DegreesDeserializer.kt @@ -0,0 +1,3 @@ +package nebulosa.api.beans.converters.angle + +class DegreesDeserializer : AngleDeserializer(false, defaultValue = 0.0) diff --git a/api/src/main/kotlin/nebulosa/api/livestacking/LiveStacker.kt b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStacker.kt deleted file mode 100644 index 7f5fed47d..000000000 --- a/api/src/main/kotlin/nebulosa/api/livestacking/LiveStacker.kt +++ /dev/null @@ -1,12 +0,0 @@ -package nebulosa.api.livestacking - -import java.nio.file.Path - -interface LiveStacker { - - fun start() - - fun add(path: Path): Path - - fun stop() -} diff --git a/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingController.kt b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingController.kt new file mode 100644 index 000000000..4865a5dce --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingController.kt @@ -0,0 +1,27 @@ +package nebulosa.api.livestacking + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import nebulosa.indi.device.camera.Camera +import org.springframework.web.bind.annotation.* +import java.nio.file.Path + +@RestController +@RequestMapping("live-stacking") +class LiveStackingController(private val liveStackingService: LiveStackingService) { + + @PutMapping("{camera}/start") + fun start(camera: Camera, @RequestBody body: LiveStackingOptions) { + liveStackingService.start(camera, body) + } + + @PutMapping("{camera}/add") + fun add(camera: Camera, @RequestParam @Valid @NotBlank path: Path): Path? { + return liveStackingService.add(camera, path) + } + + @PutMapping("{camera}/stop") + fun stop(camera: Camera) { + liveStackingService.stop(camera) + } +} diff --git a/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingOptions.kt b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingOptions.kt new file mode 100644 index 000000000..d3d2bd469 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingOptions.kt @@ -0,0 +1,28 @@ +package nebulosa.api.livestacking + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import nebulosa.api.beans.converters.angle.DegreesDeserializer +import nebulosa.livestacking.LiveStacker +import nebulosa.siril.livestacking.SirilLiveStacker +import org.jetbrains.annotations.NotNull +import java.nio.file.Files +import java.nio.file.Path +import java.util.function.Supplier + +data class LiveStackingOptions( + @JvmField val type: LiveStackerType = LiveStackerType.SIRIL, + @JvmField @field:NotNull val executablePath: Path? = null, + @JvmField val dark: Path? = null, + @JvmField val flat: Path? = null, + @JvmField @field:JsonDeserialize(using = DegreesDeserializer::class) val rotate: Double = 0.0, + @JvmField val use32Bits: Boolean = false, +) : Supplier { + + override fun get(): LiveStacker { + val workingDirectory = Files.createTempDirectory("ls-") + + return when (type) { + LiveStackerType.SIRIL -> SirilLiveStacker(executablePath!!, workingDirectory, dark, flat, rotate, use32Bits) + } + } +} diff --git a/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingService.kt b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingService.kt new file mode 100644 index 000000000..32dc2248f --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingService.kt @@ -0,0 +1,29 @@ +package nebulosa.api.livestacking + +import nebulosa.indi.device.camera.Camera +import nebulosa.livestacking.LiveStacker +import org.springframework.stereotype.Service +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap + +@Service +class LiveStackingService { + + private val liveStackers = ConcurrentHashMap(2) + + fun start(camera: Camera, options: LiveStackingOptions) { + stop(camera) + + val liveStacker = options.get() + liveStackers[camera] = liveStacker + liveStacker.start() + } + + fun add(camera: Camera, path: Path): Path? { + return liveStackers[camera]?.add(path) + } + + fun stop(camera: Camera) { + liveStackers.remove(camera)?.close() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/livestacking/SirilLiveStacker.kt b/api/src/main/kotlin/nebulosa/api/livestacking/SirilLiveStacker.kt deleted file mode 100644 index cb0e5fac2..000000000 --- a/api/src/main/kotlin/nebulosa/api/livestacking/SirilLiveStacker.kt +++ /dev/null @@ -1,76 +0,0 @@ -package nebulosa.api.livestacking - -import nebulosa.common.exec.CommandLine -import nebulosa.common.exec.LineReadListener -import nebulosa.common.exec.commandLine -import nebulosa.math.Angle -import java.nio.file.Path - -data class SirilLiveStacker( - private val executablePath: Path, - private val workingDirectory: Path, - private val dark: Path? = null, - private val flat: Path? = null, - private val rotate: Angle = 0.0, - private val use32Bits: Boolean = false, -) : LiveStacker, LineReadListener { - - @Volatile private var process: CommandLine? = null - - @Synchronized - override fun start() { - if (process == null) { - process = commandLine { - executablePath(executablePath) - putArg("-s", "-") - registerLineReadListener(this@SirilLiveStacker) - } - - process!!.whenComplete { _, e -> - println("completed. $e") - process = null - } - - process!!.start() - - process!!.writer.println(REQUIRES_COMMAND) - process!!.writer.println("$CD_COMMAND $workingDirectory") - process!!.writer.println(buildString(256) { - append(START_LS_COMMAND) - if (dark != null) append(" \"-dark=$dark\"") - if (flat != null) append(" \"-flat=$flat\"") - if (rotate != 0.0) append(" -rotate=$rotate") - if (use32Bits) append(" -32bits") - }) - } - } - - @Synchronized - override fun add(path: Path): Path { - process?.writer?.println("$LS_COMMAND $path") - return path - } - - @Synchronized - override fun stop() { - process?.writer?.println(STOP_LS_COMMAND) - process = null - } - - override fun onInputRead(line: String) { - println(line) - } - - override fun onErrorRead(line: String) { - println(line) - } - - companion object { - - private const val REQUIRES_COMMAND = "requires 1.0.0" - private const val CD_COMMAND = "cd" - private const val START_LS_COMMAND = "start_ls" - private const val LS_COMMAND = "livestack" - private const val STOP_LS_COMMAND = "stop_ls" - } -} diff --git a/api/src/test/kotlin/SirilLiveStackerTest.kt b/api/src/test/kotlin/SirilLiveStackerTest.kt index 24422f6de..41220e34e 100644 --- a/api/src/test/kotlin/SirilLiveStackerTest.kt +++ b/api/src/test/kotlin/SirilLiveStackerTest.kt @@ -1,6 +1,6 @@ import io.kotest.core.annotation.EnabledIf import io.kotest.core.spec.style.StringSpec -import nebulosa.api.livestacking.SirilLiveStacker +import nebulosa.siril.livestacking.SirilLiveStacker import nebulosa.test.NonGitHubOnlyCondition import java.nio.file.Path import kotlin.io.path.listDirectoryEntries @@ -10,16 +10,16 @@ class SirilLiveStackerTest : StringSpec() { init { "live stacking" { - val executable = Path.of("siril-cli") - val workingDir = Path.of("/home/tiagohm/Git/nebulosa/data") - val siril = SirilLiveStacker(executable, workingDir) + val executablePath = Path.of("siril-cli") + val workingDirectory = Path.of("/home/tiagohm/Git/nebulosa/data/siril") + + val siril = SirilLiveStacker(executablePath, workingDirectory) siril.start() - val fitsDir = Path.of("/home/tiagohm/Imagens/Astrophotos/Light/NGC2070/2024-04-20") + val fitsDir = Path.of("/home/tiagohm/Imagens/Astrophotos/Light/C2023_A3/2024-05-29") - for (fits in fitsDir.listDirectoryEntries()) { + for (fits in fitsDir.listDirectoryEntries().drop(140).sorted()) { siril.add(fits) - Thread.sleep(1000) } siril.stop() diff --git a/data/.gitignore b/data/.gitignore index b39285bd2..d48badef8 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -2,3 +2,4 @@ simbad/ astrobin/ test/ captures/ +siril/ diff --git a/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt b/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt index 854a3d021..02d971acc 100644 --- a/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt +++ b/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt @@ -13,7 +13,10 @@ import kotlin.io.path.exists import kotlin.io.path.inputStream import kotlin.io.path.nameWithoutExtension -class AstapStarDetector(private val path: Path, private val minSNR: Double = 0.0) : StarDetector { +data class AstapStarDetector( + private val executablePath: Path, + private val minSNR: Double = 0.0, +) : StarDetector { override fun detect(input: Path): List { val cmd = commandLine { @@ -23,6 +26,7 @@ class AstapStarDetector(private val path: Path, private val minSNR: Double = 0.0 putArg("-f", input) putArg("-z", "0") putArg("-extract", "$minSNR") + } try { cmd.start() @@ -32,28 +36,26 @@ class AstapStarDetector(private val path: Path, private val minSNR: Double = 0.0 return emptyList() } - val csvFile = Path.of("${input.parent}", input.nameWithoutExtension + ".csv") + val csvPath = Path.of("${input.parent}", "${input.nameWithoutExtension}.csv") - if (!csvFile.exists()) return emptyList() + if (!csvPath.exists()) return emptyList() - val detectedStars = ArrayList(512) + val detectedStars = ArrayList(1024) try { - csvFile.inputStream().use { + csvPath.inputStream().use { for (record in CSV_READER.ofNamedCsvRecord(InputStreamReader(it, Charsets.UTF_8))) { - detectedStars.add( - Star( - record.getField("x").toDouble(), - record.getField("y").toDouble(), - record.getField("hfd").toDouble(), - record.getField("snr").toDouble(), - record.getField("flux").toDouble(), - ) + val star = Star( + record.getField("x").toDouble(), record.getField("y").toDouble(), + record.getField("hfd").toDouble(), record.getField("snr").toDouble(), + record.getField("flux").toDouble(), ) + + detectedStars.add(star) } } } finally { - csvFile.deleteIfExists() + csvPath.deleteIfExists() } return detectedStars 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 e7e6ab19c..9bd25d14c 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 @@ -74,7 +74,7 @@ data class LocalAstrometryNetPlateSolver(private val executablePath: Path) : Pla } } - private class PlateSolutionLineReader : LineReadListener, Supplier { + private class PlateSolutionLineReader : LineReadListener.OnInput, Supplier { @Volatile private var fieldCenter: DoubleArray? = null @Volatile private var fieldRotation: Angle = 0.0 diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLine.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLine.kt index 8fac00c71..062c9cd02 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLine.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLine.kt @@ -11,6 +11,10 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit import java.util.function.Supplier +inline fun commandLine(action: CommandLine.Builder.() -> Unit): CommandLine { + return CommandLine.Builder().also(action).get() +} + data class CommandLine internal constructor( private val builder: ProcessBuilder, private val listeners: HashSet, @@ -56,7 +60,7 @@ data class CommandLine internal constructor( } @Synchronized - fun start(timeout: Duration = Duration.ZERO) { + fun start(timeout: Duration = Duration.ZERO): CommandLine { if (process == null) { process = builder.start() @@ -71,6 +75,8 @@ data class CommandLine internal constructor( waiter = ProcessWaiter(process!!, timeout.toMillis()) waiter!!.start() } + + return this } @Synchronized @@ -88,7 +94,11 @@ data class CommandLine internal constructor( errorReader = null } - override fun accept(source: CancellationSource) { + fun get(timeout: Duration): Int { + return get(timeout.toNanos(), TimeUnit.NANOSECONDS) + } + + override fun onCancel(source: CancellationSource) { stop() } diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLineDSL.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLineDSL.kt deleted file mode 100644 index cbe636ef9..000000000 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLineDSL.kt +++ /dev/null @@ -1,5 +0,0 @@ -package nebulosa.common.exec - -inline fun commandLine(action: CommandLine.Builder.() -> Unit): CommandLine { - return CommandLine.Builder().also(action).get() -} diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/exec/LineReadListener.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/exec/LineReadListener.kt index bdc537dfc..f836b4d91 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/exec/LineReadListener.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/exec/LineReadListener.kt @@ -2,7 +2,17 @@ package nebulosa.common.exec interface LineReadListener { - fun onInputRead(line: String) = Unit + fun onInputRead(line: String) - fun onErrorRead(line: String) = Unit + fun onErrorRead(line: String) + + fun interface OnInput : LineReadListener { + + override fun onErrorRead(line: String) = Unit + } + + fun interface OnError : LineReadListener { + + override fun onInputRead(line: String) = Unit + } } diff --git a/nebulosa-common/src/test/kotlin/CommandLineTest.kt b/nebulosa-common/src/test/kotlin/CommandLineTest.kt index 2887a47a6..cfc0f6593 100644 --- a/nebulosa-common/src/test/kotlin/CommandLineTest.kt +++ b/nebulosa-common/src/test/kotlin/CommandLineTest.kt @@ -25,8 +25,7 @@ class CommandLineTest : StringSpec() { } measureTimeMillis { - cmd.start() - cmd.get() shouldBeExactly 0 + cmd.start().get() shouldBeExactly 0 } shouldBeGreaterThanOrEqual 2000 } "sleep with timeout" { @@ -36,8 +35,7 @@ class CommandLineTest : StringSpec() { } measureTimeMillis { - cmd.start(Duration.ofSeconds(2)) - cmd.get() shouldNotBeExactly 0 + cmd.start(Duration.ofSeconds(2)).get() shouldNotBeExactly 0 } shouldBeGreaterThanOrEqual 2000 } "kill sleep" { @@ -49,12 +47,11 @@ class CommandLineTest : StringSpec() { thread { Thread.sleep(2000); cmd.stop() } measureTimeMillis { - cmd.start() - cmd.get() shouldNotBeExactly 0 + cmd.start().get() shouldNotBeExactly 0 } shouldBeGreaterThanOrEqual 2000 shouldBeLessThan 10000 } "ls" { - val lineReadListener = object : LineReadListener, ArrayList() { + val lineReadListener = object : LineReadListener.OnInput, ArrayList() { override fun onInputRead(line: String) { add(line) @@ -67,8 +64,7 @@ class CommandLineTest : StringSpec() { registerLineReadListener(lineReadListener) } - cmd.start() - cmd.get() shouldBeExactly 0 + cmd.start().get() shouldBeExactly 0 lineReadListener.shouldNotBeEmpty() lineReadListener.shouldContain("nebulosa-image") } diff --git a/nebulosa-livestacking/build.gradle.kts b/nebulosa-livestacking/build.gradle.kts new file mode 100644 index 000000000..4d1b2976b --- /dev/null +++ b/nebulosa-livestacking/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + kotlin("jvm") + id("maven-publish") +} + +dependencies { + api(libs.logback) +} + +publishing { + publications { + create("pluginMaven") { + from(components["java"]) + } + } +} diff --git a/nebulosa-livestacking/src/main/kotlin/nebulosa/livestacking/LiveStacker.kt b/nebulosa-livestacking/src/main/kotlin/nebulosa/livestacking/LiveStacker.kt new file mode 100644 index 000000000..0bda8575f --- /dev/null +++ b/nebulosa-livestacking/src/main/kotlin/nebulosa/livestacking/LiveStacker.kt @@ -0,0 +1,17 @@ +package nebulosa.livestacking + +import java.io.Closeable +import java.nio.file.Path + +interface LiveStacker : Closeable { + + val isRunning: Boolean + + val isStacking: Boolean + + fun start() + + fun add(path: Path): Path? + + fun stop() +} diff --git a/nebulosa-siril/build.gradle.kts b/nebulosa-siril/build.gradle.kts new file mode 100644 index 000000000..289bcd126 --- /dev/null +++ b/nebulosa-siril/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + kotlin("jvm") + id("maven-publish") +} + +dependencies { + api(project(":nebulosa-common")) + api(project(":nebulosa-math")) + api(project(":nebulosa-livestacking")) + implementation(project(":nebulosa-log")) +} + +publishing { + publications { + create("pluginMaven") { + from(components["java"]) + } + } +} diff --git a/nebulosa-siril/src/main/kotlin/nebulosa/siril/livestacking/SirilLiveStacker.kt b/nebulosa-siril/src/main/kotlin/nebulosa/siril/livestacking/SirilLiveStacker.kt new file mode 100644 index 000000000..0b6f7d4f2 --- /dev/null +++ b/nebulosa-siril/src/main/kotlin/nebulosa/siril/livestacking/SirilLiveStacker.kt @@ -0,0 +1,142 @@ +package nebulosa.siril.livestacking + +import nebulosa.common.concurrency.latch.CountUpDownLatch +import nebulosa.common.exec.CommandLine +import nebulosa.common.exec.LineReadListener +import nebulosa.common.exec.commandLine +import nebulosa.livestacking.LiveStacker +import nebulosa.log.debug +import nebulosa.log.loggerFor +import nebulosa.math.Angle +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.io.path.deleteIfExists +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name + +data class SirilLiveStacker( + private val executablePath: Path, + private val workingDirectory: Path, + private val dark: Path? = null, + private val flat: Path? = null, + private val rotate: Angle = 0.0, + private val use32Bits: Boolean = false, +) : LiveStacker, LineReadListener { + + @Volatile private var process: CommandLine? = null + + private val waitForStacking = CountUpDownLatch() + private val failed = AtomicBoolean() + + override val isRunning + get() = process != null && !process!!.isDone + + override val isStacking + get() = !waitForStacking.get() + + @Synchronized + override fun start() { + if (process == null) { + process = commandLine { + executablePath(executablePath) + putArg("-s", "-") + registerLineReadListener(this@SirilLiveStacker) + } + + process!!.whenComplete { _, e -> + println("completed. $e") + process = null + } + + process!!.start() + + process!!.writer.println(REQUIRES_COMMAND) + process!!.writer.println("$CD_COMMAND $workingDirectory") + process!!.writer.println(buildString(256) { + append(START_LS_COMMAND) + if (dark != null) append(" \"-dark=$dark\"") + if (flat != null) append(" \"-flat=$flat\"") + if (rotate != 0.0) append(" -rotate=$rotate") + if (use32Bits) append(" -32bits") + }) + } + } + + @Synchronized + override fun add(path: Path): Path? { + failed.set(false) + waitForStacking.countUp() + process?.writer?.println("$LS_COMMAND $path") + waitForStacking.await() + + return if (failed.get()) null else Path.of("$workingDirectory", "live_stack_00001.fit") + } + + @Synchronized + override fun stop() { + waitForStacking.reset() + + process?.writer?.println(STOP_LS_COMMAND) + process?.stop() + process = null + } + + override fun close() { + stop() + workingDirectory.clearStackingFiles() + } + + override fun onInputRead(line: String) { + LOG.debug { line } + + if (SUCCESSFUL_LOGS.any { line.contains(it, true) }) { + waitForStacking.reset() + } else if (FAILED_LOGS.any { line.contains(it, true) }) { + failed.set(true) + waitForStacking.reset() + } + } + + override fun onErrorRead(line: String) { + LOG.debug { line } + failed.set(true) + waitForStacking.reset() + } + + companion object { + + private const val REQUIRES_COMMAND = "requires 1.0.0" + private const val CD_COMMAND = "cd" + private const val START_LS_COMMAND = "start_ls" + private const val LS_COMMAND = "livestack" + private const val STOP_LS_COMMAND = "stop_ls" + + @JvmStatic private val LOG = loggerFor() + + @JvmStatic private val SUCCESSFUL_LOGS = arrayOf( + "Waiting for second image", + "Stacked image", + ) + + @JvmStatic private val FAILED_LOGS = arrayOf( + "Not enough stars", + "Sequence processing partially succeeded", + ) + + @JvmStatic private val LIVE_STACK_FIT_REGEX = Regex("live_stack_\\d+.fit") + @JvmStatic private val LIVE_STACK_SEQ_REGEX = Regex("live_stack_\\d*.seq") + + @JvmStatic + private fun Path.clearStackingFiles() { + for (file in listDirectoryEntries("*")) { + val name = file.name + + if (LIVE_STACK_FIT_REGEX.matches(name) || + LIVE_STACK_SEQ_REGEX.matches(name) + ) { + file.deleteIfExists() + } + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0e03d366f..c9c622236 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -69,6 +69,7 @@ include(":nebulosa-indi-client") include(":nebulosa-indi-device") include(":nebulosa-indi-protocol") include(":nebulosa-io") +include(":nebulosa-livestacking") include(":nebulosa-log") include(":nebulosa-lx200-protocol") include(":nebulosa-math") @@ -80,6 +81,7 @@ include(":nebulosa-plate-solving") include(":nebulosa-retrofit") include(":nebulosa-sbd") include(":nebulosa-simbad") +include(":nebulosa-siril") include(":nebulosa-skycatalog") include(":nebulosa-skycatalog-hyg") include(":nebulosa-skycatalog-sao") From 315317929b1e0bddd8b1df32b1337db06b84b886 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sun, 2 Jun 2024 17:13:40 -0300 Subject: [PATCH 3/6] [api][desktop]: Support Live Stacking --- .../api/alignment/polar/tppa/TPPATask.kt | 2 +- .../nebulosa/api/autofocus/AutoFocusTask.kt | 2 +- .../api/cameras/CameraCaptureEvent.kt | 14 +- .../nebulosa/api/cameras/CameraCaptureTask.kt | 61 ++++++--- .../api/cameras/CameraStartCaptureRequest.kt | 2 + .../livestacking/LiveStackingController.kt | 2 +- ...ckingOptions.kt => LiveStackingRequest.kt} | 8 +- .../api/livestacking/LiveStackingService.kt | 4 +- .../api/wizard/flat/FlatWizardTask.kt | 2 +- .../src/app/alignment/alignment.component.ts | 4 +- desktop/src/app/app.component.html | 7 + desktop/src/app/app.module.ts | 2 + .../src/app/autofocus/autofocus.component.ts | 4 +- desktop/src/app/camera/camera.component.html | 55 +++++++- desktop/src/app/camera/camera.component.ts | 127 ++++++++++-------- .../app/filterwheel/filterwheel.component.ts | 10 +- .../app/flat-wizard/flat-wizard.component.ts | 4 +- desktop/src/app/focuser/focuser.component.ts | 7 +- desktop/src/app/guider/guider.component.ts | 4 +- desktop/src/app/image/image.component.ts | 74 +++++++--- desktop/src/app/mount/mount.component.ts | 7 +- desktop/src/app/rotator/rotator.component.ts | 7 +- .../src/app/sequencer/sequencer.component.ts | 17 ++- .../path-chooser/path-chooser.component.html | 8 ++ .../path-chooser/path-chooser.component.scss | 0 .../path-chooser/path-chooser.component.ts | 53 ++++++++ desktop/src/shared/types/camera.types.ts | 37 +++-- desktop/src/shared/types/image.types.ts | 1 + desktop/src/styles.scss | 4 +- .../astap/star/detection/AstapStarDetector.kt | 2 +- .../nebulosa/common/exec/CommandLine.kt | 3 + .../siril/livestacking/SirilLiveStacker.kt | 62 +++++---- 32 files changed, 415 insertions(+), 181 deletions(-) rename api/src/main/kotlin/nebulosa/api/livestacking/{LiveStackingOptions.kt => LiveStackingRequest.kt} (86%) create mode 100644 desktop/src/shared/components/path-chooser/path-chooser.component.html create mode 100644 desktop/src/shared/components/path-chooser/path-chooser.component.scss create mode 100644 desktop/src/shared/components/path-chooser/path-chooser.component.ts diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt index e3ae706ff..b3d8e926c 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt @@ -84,7 +84,7 @@ data class TPPATask( captureEvent = event if (event.state == CameraCaptureState.EXPOSURE_FINISHED) { - savedImage = event.savePath!! + savedImage = event.savedPath!! } if (!finished.get()) { diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusTask.kt b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusTask.kt index 249e5c51d..d1231501b 100644 --- a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusTask.kt +++ b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusTask.kt @@ -220,7 +220,7 @@ data class AutoFocusTask( if (event.state == CameraCaptureState.EXPOSURE_FINISHED) { sendEvent(AutoFocusState.EXPOSURED, event) sendEvent(AutoFocusState.ANALYSING) - val detectedStars = starDetection.detect(event.savePath!!) + val detectedStars = starDetection.detect(event.savedPath!!) starCount = detectedStars.size LOG.info("detected $starCount stars") starHFD = detectedStars.measureDetectedStars() diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt index 805551724..bf115e2e1 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt @@ -1,12 +1,14 @@ package nebulosa.api.cameras +import com.fasterxml.jackson.annotation.JsonIgnore import nebulosa.api.messages.MessageEvent import nebulosa.indi.device.camera.Camera import java.nio.file.Path import java.time.Duration data class CameraCaptureEvent( - @JvmField val camera: Camera, + @JvmField @field:JsonIgnore val task: CameraCaptureTask, + @JvmField val camera: Camera = task.camera, @JvmField val state: CameraCaptureState = CameraCaptureState.IDLE, @JvmField val exposureAmount: Int = 0, @JvmField val exposureCount: Int = 0, @@ -16,15 +18,9 @@ data class CameraCaptureEvent( @JvmField val stepRemainingTime: Duration = Duration.ZERO, @JvmField val stepElapsedTime: Duration = Duration.ZERO, @JvmField val stepProgress: Double = 0.0, - @JvmField val savePath: Path? = null, + @JvmField val savedPath: Path? = null, + @JvmField val liveStackedSavedPath: Path? = null, ) : MessageEvent { override val eventName = "CAMERA.CAPTURE_ELAPSED" - - companion object { - - @JvmStatic - fun exposureFinished(camera: Camera, savePath: Path) = - CameraCaptureEvent(camera, CameraCaptureState.EXPOSURE_FINISHED, savePath = savePath) - } } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt index 6d6ade6f0..752855e01 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt @@ -12,6 +12,7 @@ import nebulosa.common.concurrency.cancel.CancellationToken import nebulosa.guiding.Guider import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.CameraEvent +import nebulosa.livestacking.LiveStacker import nebulosa.log.loggerFor import java.nio.file.Path import java.time.Duration @@ -32,7 +33,6 @@ data class CameraCaptureTask( private val cameraExposureTask = CameraExposureTask(camera, request) private val ditherAfterExposureTask = DitherAfterExposureTask(guider, request.dither) - @Volatile private var state = CameraCaptureState.IDLE @Volatile private var exposureCount = 0 @Volatile private var captureRemainingTime = Duration.ZERO @Volatile private var prevCaptureElapsedTime = Duration.ZERO @@ -41,12 +41,14 @@ data class CameraCaptureTask( @Volatile private var stepRemainingTime = Duration.ZERO @Volatile private var stepElapsedTime = Duration.ZERO @Volatile private var stepProgress = 0.0 - @Volatile private var savePath: Path? = null + @Volatile private var savedPath: Path? = null + @Volatile private var liveStackedSavedPath: Path? = null @JvmField @JsonIgnore val estimatedCaptureTime: Duration = if (request.isLoop) Duration.ZERO else Duration.ofNanos(request.exposureTime.toNanos() * request.exposureAmount + request.exposureDelay.toNanos() * (request.exposureAmount - if (useFirstExposure) 0 else 1)) @Volatile private var exposureRepeatCount = 0 + @Volatile private var liveStacker: LiveStacker? = null init { delayTask.subscribe(this) @@ -67,14 +69,28 @@ data class CameraCaptureTask( cameraExposureTask.reset() + liveStacker?.close() + liveStacker = null + + if (request.liveStacking.enabled && (request.isLoop || request.exposureAmount > 1 || exposureMaxRepeat > 1)) { + try { + liveStacker = request.liveStacking.get() + liveStacker!!.start() + } catch (e: Throwable) { + LOG.error("failed to start live stacking. request={}", request.liveStacking, e) + + liveStacker?.close() + liveStacker = null + } + } + while (!cancellationToken.isCancelled && !cameraExposureTask.isAborted && ((exposureMaxRepeat > 0 && exposureRepeatCount < exposureMaxRepeat) || (exposureMaxRepeat <= 0 && (request.isLoop || exposureCount < request.exposureAmount))) ) { if (exposureCount == 0) { - state = CameraCaptureState.CAPTURE_STARTED - sendEvent() + sendEvent(CameraCaptureState.CAPTURE_STARTED) if (guider != null) { if (useFirstExposure) { @@ -107,11 +123,9 @@ data class CameraCaptureTask( } } - if (state != CameraCaptureState.CAPTURE_FINISHED) { - state = CameraCaptureState.CAPTURE_FINISHED - sendEvent() - } + sendEvent(CameraCaptureState.CAPTURE_FINISHED) + liveStacker?.close() exposureRepeatCount = 0 LOG.info("Camera Capture finished. camera={}, request={}, exposureCount={}", camera, request, exposureCount) @@ -119,61 +133,66 @@ data class CameraCaptureTask( @Synchronized override fun accept(event: Any) { - when (event) { + val state = when (event) { is DelayEvent -> { - state = CameraCaptureState.WAITING captureElapsedTime += event.waitTime stepElapsedTime = event.task.duration - event.remainingTime stepRemainingTime = event.remainingTime stepProgress = event.progress + CameraCaptureState.WAITING } is CameraExposureEvent -> { when (event.state) { CameraExposureState.STARTED -> { - state = CameraCaptureState.EXPOSURE_STARTED prevCaptureElapsedTime = captureElapsedTime exposureCount++ exposureRepeatCount++ + CameraCaptureState.EXPOSURE_STARTED } CameraExposureState.ELAPSED -> { - state = CameraCaptureState.EXPOSURING captureElapsedTime = prevCaptureElapsedTime + event.elapsedTime stepElapsedTime = event.elapsedTime stepRemainingTime = event.remainingTime stepProgress = event.progress + CameraCaptureState.EXPOSURING } CameraExposureState.FINISHED -> { - state = CameraCaptureState.EXPOSURE_FINISHED captureElapsedTime = prevCaptureElapsedTime + request.exposureTime - savePath = event.savedPath + savedPath = event.savedPath + liveStackedSavedPath = addFrameToLiveStacker(savedPath) + CameraCaptureState.EXPOSURE_FINISHED } CameraExposureState.IDLE -> { - state = CameraCaptureState.CAPTURE_FINISHED + CameraCaptureState.CAPTURE_FINISHED } } } else -> return LOG.warn("unknown event: {}", event) } - sendEvent() + sendEvent(state) } - private fun sendEvent() { + private fun sendEvent(state: CameraCaptureState) { if (state != CameraCaptureState.IDLE && !request.isLoop) { captureRemainingTime = if (estimatedCaptureTime > captureElapsedTime) estimatedCaptureTime - captureElapsedTime else Duration.ZERO captureProgress = (estimatedCaptureTime - captureRemainingTime).toNanos().toDouble() / estimatedCaptureTime.toNanos() } val event = CameraCaptureEvent( - camera, state, request.exposureAmount, exposureCount, + this, camera, state, request.exposureAmount, exposureCount, captureRemainingTime, captureElapsedTime, captureProgress, stepRemainingTime, stepElapsedTime, stepProgress, - savePath + savedPath, liveStackedSavedPath ) onNext(event) } + private fun addFrameToLiveStacker(path: Path?): Path? { + return liveStacker?.add(path ?: return null) + } + override fun close() { delayTask.close() waitForSettleTask.close() @@ -184,7 +203,6 @@ data class CameraCaptureTask( } override fun reset() { - state = CameraCaptureState.IDLE exposureCount = 0 captureRemainingTime = Duration.ZERO prevCaptureElapsedTime = Duration.ZERO @@ -193,7 +211,8 @@ data class CameraCaptureTask( stepRemainingTime = Duration.ZERO stepElapsedTime = Duration.ZERO stepProgress = 0.0 - savePath = null + savedPath = null + liveStackedSavedPath = null delayTask.reset() cameraExposureTask.reset() diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt index c39ab7323..e4fc96e42 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt @@ -4,6 +4,7 @@ import jakarta.validation.Valid import jakarta.validation.constraints.Positive import jakarta.validation.constraints.PositiveOrZero import nebulosa.api.guiding.DitherAfterExposureRequest +import nebulosa.api.livestacking.LiveStackingRequest import nebulosa.indi.device.camera.FrameType import org.hibernate.validator.constraints.Range import org.hibernate.validator.constraints.time.DurationMax @@ -35,6 +36,7 @@ data class CameraStartCaptureRequest( @JvmField val autoSubFolderMode: AutoSubFolderMode = AutoSubFolderMode.OFF, @field:Valid @JvmField val dither: DitherAfterExposureRequest = DitherAfterExposureRequest.DISABLED, @JvmField val calibrationGroup: String? = null, + @JvmField val liveStacking: LiveStackingRequest =LiveStackingRequest.EMPTY, // Filter Wheel. @JvmField val filterPosition: Int = 0, @JvmField val shutterPosition: Int = 0, diff --git a/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingController.kt b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingController.kt index 4865a5dce..b953b7ee4 100644 --- a/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingController.kt +++ b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingController.kt @@ -11,7 +11,7 @@ import java.nio.file.Path class LiveStackingController(private val liveStackingService: LiveStackingService) { @PutMapping("{camera}/start") - fun start(camera: Camera, @RequestBody body: LiveStackingOptions) { + fun start(camera: Camera, @RequestBody body: LiveStackingRequest) { liveStackingService.start(camera, body) } diff --git a/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingOptions.kt b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingRequest.kt similarity index 86% rename from api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingOptions.kt rename to api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingRequest.kt index d3d2bd469..9f26e144b 100644 --- a/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingOptions.kt +++ b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingRequest.kt @@ -9,7 +9,8 @@ import java.nio.file.Files import java.nio.file.Path import java.util.function.Supplier -data class LiveStackingOptions( +data class LiveStackingRequest( + @JvmField val enabled: Boolean = false, @JvmField val type: LiveStackerType = LiveStackerType.SIRIL, @JvmField @field:NotNull val executablePath: Path? = null, @JvmField val dark: Path? = null, @@ -25,4 +26,9 @@ data class LiveStackingOptions( LiveStackerType.SIRIL -> SirilLiveStacker(executablePath!!, workingDirectory, dark, flat, rotate, use32Bits) } } + + companion object { + + @JvmStatic val EMPTY = LiveStackingRequest() + } } diff --git a/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingService.kt b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingService.kt index 32dc2248f..a749b6361 100644 --- a/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingService.kt +++ b/api/src/main/kotlin/nebulosa/api/livestacking/LiveStackingService.kt @@ -11,7 +11,7 @@ class LiveStackingService { private val liveStackers = ConcurrentHashMap(2) - fun start(camera: Camera, options: LiveStackingOptions) { + fun start(camera: Camera, options: LiveStackingRequest) { stop(camera) val liveStacker = options.get() @@ -24,6 +24,6 @@ class LiveStackingService { } fun stop(camera: Camera) { - liveStackers.remove(camera)?.close() + liveStackers.remove(camera)?.stop() } } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt index 8a0a0ec3d..4dc26907e 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt @@ -65,7 +65,7 @@ data class FlatWizardTask( capture = event if (event.state == CameraCaptureState.EXPOSURE_FINISHED) { - savedPath = event.savePath!! + savedPath = event.savedPath!! onNext(event) } diff --git a/desktop/src/app/alignment/alignment.component.ts b/desktop/src/app/alignment/alignment.component.ts index ad4ceb05f..f22211ac1 100644 --- a/desktop/src/app/alignment/alignment.component.ts +++ b/desktop/src/app/alignment/alignment.component.ts @@ -224,11 +224,11 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy, Pingable { }) this.loadPreference() - - pinger.register(this, 30000) } async ngAfterViewInit() { + this.pinger.register(this, 30000) + this.cameras = (await this.api.cameras()).sort(deviceComparator) this.mounts = (await this.api.mounts()).sort(deviceComparator) this.guideOutputs = (await this.api.guideOutputs()).sort(deviceComparator) diff --git a/desktop/src/app/app.component.html b/desktop/src/app/app.component.html index d6c47560e..37f7d7c2c 100644 --- a/desktop/src/app/app.component.html +++ b/desktop/src/app/app.component.html @@ -6,9 +6,16 @@ + @if (e.toggleable) { +
+ {{ e.label }} + +
+ } @else { + }
diff --git a/desktop/src/app/app.module.ts b/desktop/src/app/app.module.ts index dd8e85990..0826d8f46 100644 --- a/desktop/src/app/app.module.ts +++ b/desktop/src/app/app.module.ts @@ -84,6 +84,7 @@ import { MountComponent } from './mount/mount.component' import { RotatorComponent } from './rotator/rotator.component' import { SequencerComponent } from './sequencer/sequencer.component' import { SettingsComponent } from './settings/settings.component' +import { PathChooserComponent } from '../shared/components/path-chooser/path-chooser.component' @NgModule({ declarations: [ @@ -121,6 +122,7 @@ import { SettingsComponent } from './settings/settings.component' MoonComponent, MountComponent, NoDropdownDirective, + PathChooserComponent, RotatorComponent, SequencerComponent, SettingsComponent, diff --git a/desktop/src/app/autofocus/autofocus.component.ts b/desktop/src/app/autofocus/autofocus.component.ts index cec4ab6ab..482e92755 100644 --- a/desktop/src/app/autofocus/autofocus.component.ts +++ b/desktop/src/app/autofocus/autofocus.component.ts @@ -302,11 +302,11 @@ export class AutoFocusComponent implements AfterViewInit, OnDestroy, Pingable { }) this.loadPreference() - - pinger.register(this, 30000) } async ngAfterViewInit() { + this.pinger.register(this, 30000) + this.cameras = (await this.api.cameras()).sort(deviceComparator) this.focusers = (await this.api.focusers()).sort(deviceComparator) } diff --git a/desktop/src/app/camera/camera.component.html b/desktop/src/app/camera/camera.component.html index c082be6b0..87a37928d 100644 --- a/desktop/src/app/camera/camera.component.html +++ b/desktop/src/app/camera/camera.component.html @@ -221,28 +221,71 @@
Enabled - +
RA only -
-
- + [(ngModel)]="request.dither.afterExposures" [step]="1" (ngModelChange)="savePreference()" scrollableNumber />
+ + + +
+
+ Enabled + +
+
+ + + + +
+
+ 32-bits (slower) + +
+
+ + + + +
+
+ +
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/desktop/src/app/camera/camera.component.ts b/desktop/src/app/camera/camera.component.ts index 2d883b3e4..c568ae3ad 100644 --- a/desktop/src/app/camera/camera.component.ts +++ b/desktop/src/app/camera/camera.component.ts @@ -75,43 +75,57 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { } showDitherDialog = false + showLiveStackingDialog = false calibrationModel: MenuItem[] = [] - readonly cameraModel: MenuItem[] = [ - { - icon: 'icomoon random-dither', - label: 'Dither', - command: () => { - this.showDitherDialog = true - }, + private readonly ditherMenuItem: MenuItem = { + icon: 'icomoon random-dither', + label: 'Dither', + command: () => { + this.showDitherDialog = true }, - { - icon: 'mdi mdi-connection', - label: 'Snoop Devices', - subMenu: [ - { - icon: 'mdi mdi-telescope', - label: 'Mount', - subMenu: [], - }, - { - icon: 'mdi mdi-palette', - label: 'Filter Wheel', - subMenu: [], - }, - { - icon: 'mdi mdi-image-filter-center-focus', - label: 'Focuser', - subMenu: [], - }, - { - icon: 'mdi mdi-rotate-right', - label: 'Rotator', - subMenu: [], - }, - ] + } + + private readonly liveStackingMenuItem: MenuItem = { + icon: 'mdi mdi-image-multiple', + label: 'Live Stacking', + command: () => { + this.showLiveStackingDialog = true }, + } + + private readonly snoopDevicesMenuItem: MenuItem = { + icon: 'mdi mdi-connection', + label: 'Snoop Devices', + subMenu: [ + { + icon: 'mdi mdi-telescope', + label: 'Mount', + subMenu: [], + }, + { + icon: 'mdi mdi-palette', + label: 'Filter Wheel', + subMenu: [], + }, + { + icon: 'mdi mdi-image-filter-center-focus', + label: 'Focuser', + subMenu: [], + }, + { + icon: 'mdi mdi-rotate-right', + label: 'Rotator', + subMenu: [], + }, + ] + } + + readonly cameraModel: MenuItem[] = [ + this.ditherMenuItem, + this.liveStackingMenuItem, + this.snoopDevicesMenuItem, ] hasDewHeater = false @@ -246,27 +260,27 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { } }) - this.cameraModel[1].visible = !app.modal - - pinger.register(this, 30000) + this.snoopDevicesMenuItem.visible = !app.modal } ngAfterContentInit() { - this.route.queryParams.subscribe(e => { + this.route.queryParams.subscribe(async e => { const decodedData = JSON.parse(decodeURIComponent(e.data)) if (this.app.modal) { - this.loadCameraStartCaptureForDialogMode(decodedData) + await this.loadCameraStartCaptureForDialogMode(decodedData) } else { - this.cameraChanged(decodedData) + await this.cameraChanged(decodedData) } - }) - if (!this.app.modal) { - this.loadEquipment() - } + this.pinger.register(this, 30000) + + if (!this.app.modal) { + this.loadEquipment() + } - this.loadCalibrationGroups() + this.loadCalibrationGroups() + }) } @HostListener('window:unload') @@ -355,10 +369,10 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { return makeItem(this.equipment.mount?.name === mount?.name, () => this.equipment.mount = mount, mount) } - this.cameraModel[1].subMenu![0].subMenu!.push(makeMountItem()) + this.snoopDevicesMenuItem.subMenu![0].subMenu!.push(makeMountItem()) for (const mount of mounts) { - this.cameraModel[1].subMenu![0].subMenu!.push(makeMountItem(mount)) + this.snoopDevicesMenuItem.subMenu![0].subMenu!.push(makeMountItem(mount)) } // FILTER WHEEL @@ -370,10 +384,10 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { return makeItem(this.equipment.wheel?.name === wheel?.name, () => this.equipment.wheel = wheel, wheel) } - this.cameraModel[1].subMenu![1].subMenu!.push(makeWheelItem()) + this.snoopDevicesMenuItem.subMenu![1].subMenu!.push(makeWheelItem()) for (const wheel of wheels) { - this.cameraModel[1].subMenu![1].subMenu!.push(makeWheelItem(wheel)) + this.snoopDevicesMenuItem.subMenu![1].subMenu!.push(makeWheelItem(wheel)) } // FOCUSER @@ -385,10 +399,10 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { return makeItem(this.equipment.focuser?.name === focuser?.name, () => this.equipment.focuser = focuser, focuser) } - this.cameraModel[1].subMenu![2].subMenu!.push(makeFocuserItem()) + this.snoopDevicesMenuItem.subMenu![2].subMenu!.push(makeFocuserItem()) for (const focuser of focusers) { - this.cameraModel[1].subMenu![2].subMenu!.push(makeFocuserItem(focuser)) + this.snoopDevicesMenuItem.subMenu![2].subMenu!.push(makeFocuserItem(focuser)) } // ROTATOR @@ -400,10 +414,10 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { return makeItem(this.equipment.rotator?.name === rotator?.name, () => this.equipment.rotator = rotator, rotator) } - this.cameraModel[1].subMenu![3].subMenu!.push(makeRotatorItem()) + this.snoopDevicesMenuItem.subMenu![3].subMenu!.push(makeRotatorItem()) for (const rotator of rotators) { - this.cameraModel[1].subMenu![3].subMenu!.push(makeRotatorItem(rotator)) + this.snoopDevicesMenuItem.subMenu![3].subMenu!.push(makeRotatorItem(rotator)) } buildStartTooltip() @@ -631,10 +645,13 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { this.request.offset = preference.offset ?? 0 this.request.frameFormat = preference.frameFormat ?? (this.camera.frameFormats[0] || '') - this.request.dither!.enabled = preference.dither?.enabled ?? false - this.request.dither!.raOnly = preference.dither?.raOnly ?? false - this.request.dither!.amount = preference.dither?.amount ?? 1.5 - this.request.dither!.afterExposures = preference.dither?.afterExposures ?? 1 + if (preference.dither) { + Object.assign(this.request.dither, preference.dither) + } + + if (preference.liveStacking) { + Object.assign(this.request.liveStacking, preference.liveStacking) + } Object.assign(this.equipment, this.preference.equipmentForDevice(this.camera).get()) } diff --git a/desktop/src/app/filterwheel/filterwheel.component.ts b/desktop/src/app/filterwheel/filterwheel.component.ts index 18ee4ab06..9cbdf65e0 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.ts +++ b/desktop/src/app/filterwheel/filterwheel.component.ts @@ -134,22 +134,22 @@ export class FilterWheelComponent implements AfterContentInit, OnDestroy, Pingab hotkeys('7', event => { event.preventDefault(); this.moveToPosition(7) }) hotkeys('8', event => { event.preventDefault(); this.moveToPosition(8) }) hotkeys('9', event => { event.preventDefault(); this.moveToPosition(9) }) - - pinger.register(this, 30000) } async ngAfterContentInit() { - this.route.queryParams.subscribe(e => { + this.route.queryParams.subscribe(async e => { const decodedData = JSON.parse(decodeURIComponent(e.data)) if (this.app.modal) { const request = decodedData as WheelDialogInput Object.assign(this.request, request.request) this.mode = request.mode - this.wheelChanged(request.wheel) + await this.wheelChanged(request.wheel) } else { - this.wheelChanged(decodedData) + await this.wheelChanged(decodedData) } + + this.pinger.register(this, 30000) }) this.focusers = await this.api.focusers() diff --git a/desktop/src/app/flat-wizard/flat-wizard.component.ts b/desktop/src/app/flat-wizard/flat-wizard.component.ts index 41a887d19..fd02d6764 100644 --- a/desktop/src/app/flat-wizard/flat-wizard.component.ts +++ b/desktop/src/app/flat-wizard/flat-wizard.component.ts @@ -143,11 +143,11 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy, Pingable { } }) }) - - pinger.register(this, 30000) } async ngAfterViewInit() { + this.pinger.register(this, 30000) + this.cameras = (await this.api.cameras()).sort(deviceComparator) this.wheels = (await this.api.wheels()).sort(deviceComparator) } diff --git a/desktop/src/app/focuser/focuser.component.ts b/desktop/src/app/focuser/focuser.component.ts index 1c1221264..c069d991f 100644 --- a/desktop/src/app/focuser/focuser.component.ts +++ b/desktop/src/app/focuser/focuser.component.ts @@ -61,14 +61,13 @@ export class FocuserComponent implements AfterViewInit, OnDestroy, Pingable { hotkeys('down', event => { event.preventDefault(); this.stepsRelative = Math.max(0, this.stepsRelative - 1) }) hotkeys('ctrl+up', event => { event.preventDefault(); this.stepsAbsolute = Math.max(0, this.stepsAbsolute - 1) }) hotkeys('ctrl+down', event => { event.preventDefault(); this.stepsAbsolute = Math.min(this.focuser.maxPosition, this.stepsAbsolute + 1) }) - - pinger.register(this, 30000) } async ngAfterViewInit() { - this.route.queryParams.subscribe(e => { + this.route.queryParams.subscribe(async e => { const focuser = JSON.parse(decodeURIComponent(e.data)) as Focuser - this.focuserChanged(focuser) + await this.focuserChanged(focuser) + this.pinger.register(this, 30000) }) } diff --git a/desktop/src/app/guider/guider.component.ts b/desktop/src/app/guider/guider.component.ts index ac16de440..8b05c6572 100644 --- a/desktop/src/app/guider/guider.component.ts +++ b/desktop/src/app/guider/guider.component.ts @@ -285,11 +285,11 @@ export class GuiderComponent implements AfterViewInit, OnDestroy, Pingable { this.message = event.data }) }) - - pinger.register(this, 30000) } async ngAfterViewInit() { + this.pinger.register(this, 30000) + const settle = await this.api.getGuidingSettle() this.settleAmount = settle.amount ?? 1.5 diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 4a8dbeaff..1c5c4af80 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -51,6 +51,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { imageInfo?: ImageInfo private imageURL!: string imageData: ImageData = {} + showLiveStackedImage?: boolean readonly scnrChannels: { name: string, value?: ImageChannel }[] = [ { name: 'None', value: undefined }, @@ -303,7 +304,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { disabled: true, command: () => { this.executeMount(mount => { - this.api.pointMountHere(mount, this.imageData.path!, this.imageMouseX, this.imageMouseY) + this.api.pointMountHere(mount, this.imagePath!, this.imageMouseX, this.imageMouseY) }) }, } @@ -453,6 +454,10 @@ export class ImageComponent implements AfterViewInit, OnDestroy { && !this.transformation.mirrorVertical } + get imagePath() { + return (this.showLiveStackedImage && this.imageData.liveStackedPath) || this.imageData.path + } + constructor( private app: AppComponent, private route: ActivatedRoute, @@ -465,6 +470,18 @@ export class ImageComponent implements AfterViewInit, OnDestroy { ) { app.title = 'Image' + app.topMenu.push({ + label: 'Live Stacking', + toggleable: true, + visible: false, + toggle: (event) => { + if (event.originalEvent) { + this.showLiveStackedImage = !!event.checked + this.loadImage(true) + } + }, + }) + app.topMenu.push({ icon: 'mdi mdi-fullscreen', label: 'Fullscreen', @@ -509,19 +526,29 @@ export class ImageComponent implements AfterViewInit, OnDestroy { electron.on('CAMERA.CAPTURE_ELAPSED', async (event) => { if (event.state === 'EXPOSURE_FINISHED' && event.camera.id === this.imageData.camera?.id) { - await this.closeImage(true) - ngZone.run(() => { - this.imageData.path = event.savePath + if (this.showLiveStackedImage === undefined) { + if (event.liveStackedSavedPath) { + this.showLiveStackedImage = true + this.app.topMenu[0].toggled = true + this.app.topMenu[0].visible = true + } + } else if (!event.liveStackedSavedPath) { + this.showLiveStackedImage = undefined + this.app.topMenu[0].toggled = false + this.app.topMenu[0].visible = false + } + + this.imageData.path = event.savedPath + this.imageData.liveStackedPath = event.liveStackedSavedPath + this.clearOverlay() - this.loadImage() + this.loadImage(true) }) } }) electron.on('DATA.CHANGED', async (event: ImageData) => { - await this.closeImage(event.path !== this.imageData.path) - ngZone.run(() => { this.loadImageFromData(event) }) @@ -634,10 +661,11 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } private async closeImage(force: boolean = false) { - if (this.imageData.path) { - if (force) { - await this.api.closeImage(this.imageData.path) - } + if (this.imageData.path && force) { + await this.api.closeImage(this.imageData.path) + } + if (this.imageData.liveStackedPath && force) { + await this.api.closeImage(this.imageData.liveStackedPath) } } @@ -720,7 +748,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } this.clearOverlay() - this.loadImage() + this.loadImage(true) } private clearOverlay() { @@ -738,7 +766,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } private async computeHistogram() { - const data = await this.api.imageHistogram(this.imageData.path!, this.statisticsBitLength.bitLength) + const data = await this.api.imageHistogram(this.imagePath!, this.statisticsBitLength.bitLength) this.histogram.update(data) } @@ -749,7 +777,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { async detectStars() { const options = this.preference.starDetectionOptions(this.starDetection.type).get() options.minSNR = this.starDetection.minSNR - this.starDetection.stars = await this.api.detectStars(this.imageData.path!, options) + this.starDetection.stars = await this.api.detectStars(this.imagePath!, options) let hfd = 0 let snr = 0 @@ -784,9 +812,13 @@ export class ImageComponent implements AfterViewInit, OnDestroy { ctx?.drawImage(this.image.nativeElement, star.x - 8, star.y - 8, 16, 16, 0, 0, canvas.width, canvas.height) } - private async loadImage() { - if (this.imageData.path) { - await this.loadImageFromPath(this.imageData.path) + private async loadImage(force: boolean = false) { + await this.closeImage(force) + + const path = this.imagePath + + if (path) { + await this.loadImageFromPath(path) } if (this.imageData.title) { @@ -861,14 +893,14 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } async saveImageAs() { - await this.api.saveImageAs(this.imageData!.path!, this.saveAs, this.imageData.camera) + await this.api.saveImageAs(this.imagePath!, this.saveAs, this.imageData.camera) this.saveAs.showDialog = false } async annotateImage() { try { this.annotating = true - this.annotations = await this.api.annotationsOfImage(this.imageData.path!, this.annotation.useStarsAndDSOs, + this.annotations = await this.api.annotationsOfImage(this.imagePath!, this.annotation.useStarsAndDSOs, this.annotation.useMinorPlanets, this.annotation.minorPlanetsMagLimit, this.annotation.useSimbad) this.annotationIsVisible = true this.annotationMenuItem.toggleable = this.annotations.length > 0 @@ -983,7 +1015,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } private async retrieveCoordinateInterpolation() { - const coordinate = await this.api.coordinateInterpolation(this.imageData.path!) + const coordinate = await this.api.coordinateInterpolation(this.imagePath!) if (coordinate) { const { ma, md, x0, y0, x1, y1, delta } = coordinate @@ -1002,7 +1034,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { try { const solver = this.preference.plateSolverOptions(this.solver.type).get() - const solved = await this.api.solveImage(solver, this.imageData.path!, this.solver.blind, + const solved = await this.api.solveImage(solver, this.imagePath!, this.solver.blind, this.solver.centerRA, this.solver.centerDEC, this.solver.radius) this.savePreference() diff --git a/desktop/src/app/mount/mount.component.ts b/desktop/src/app/mount/mount.component.ts index 604dd02de..fe4fbad8d 100644 --- a/desktop/src/app/mount/mount.component.ts +++ b/desktop/src/app/mount/mount.component.ts @@ -279,14 +279,13 @@ export class MountComponent implements AfterContentInit, OnDestroy, Pingable { hotkeys('e', { keyup: true }, event => { event.preventDefault(); this.moveTo('NE', event.type === 'keydown') }) hotkeys('z', { keyup: true }, event => { event.preventDefault(); this.moveTo('SW', event.type === 'keydown') }) hotkeys('c', { keyup: true }, event => { event.preventDefault(); this.moveTo('SE', event.type === 'keydown') }) - - this.pinger.register(this, 30000) } async ngAfterContentInit() { - this.route.queryParams.subscribe(e => { + this.route.queryParams.subscribe(async e => { const mount = JSON.parse(decodeURIComponent(e.data)) as Mount - this.mountChanged(mount) + await this.mountChanged(mount) + this.pinger.register(this, 30000) }) } diff --git a/desktop/src/app/rotator/rotator.component.ts b/desktop/src/app/rotator/rotator.component.ts index d9a8215dc..6886e7044 100644 --- a/desktop/src/app/rotator/rotator.component.ts +++ b/desktop/src/app/rotator/rotator.component.ts @@ -47,14 +47,13 @@ export class RotatorComponent implements AfterViewInit, OnDestroy, Pingable { }) } }) - - pinger.register(this, 30000) } async ngAfterViewInit() { - this.route.queryParams.subscribe(e => { + this.route.queryParams.subscribe(async e => { const rotator = JSON.parse(decodeURIComponent(e.data)) as Rotator - this.rotatorChanged(rotator) + await this.rotatorChanged(rotator) + this.pinger.register(this, 30000) }) } diff --git a/desktop/src/app/sequencer/sequencer.component.ts b/desktop/src/app/sequencer/sequencer.component.ts index bde94f520..c17bd92d8 100644 --- a/desktop/src/app/sequencer/sequencer.component.ts +++ b/desktop/src/app/sequencer/sequencer.component.ts @@ -237,11 +237,11 @@ export class SequencerComponent implements AfterContentInit, OnDestroy, Pingable for (const p of SEQUENCE_ENTRY_PROPERTIES) { this.availableEntryPropertiesToApply.set(p, true) } - - pinger.register(this, 30000) } async ngAfterContentInit() { + this.pinger.register(this, 30000) + this.cameras = (await this.api.cameras()).sort(deviceComparator) this.mounts = (await this.api.mounts()).sort(deviceComparator) this.wheels = (await this.api.wheels()).sort(deviceComparator) @@ -290,6 +290,19 @@ export class SequencerComponent implements AfterContentInit, OnDestroy, Pingable frameFormat: camera?.frameFormats[0], autoSave: true, autoSubFolderMode: 'OFF', + dither: { + enabled: false, + amount: 0, + raOnly: false, + afterExposures: 0 + }, + liveStacking: { + enabled: false, + type: 'SIRIL', + executablePath: '', + rotate: 0, + use32Bits: false + }, }) this.savePlan() diff --git a/desktop/src/shared/components/path-chooser/path-chooser.component.html b/desktop/src/shared/components/path-chooser/path-chooser.component.html new file mode 100644 index 000000000..562ddc331 --- /dev/null +++ b/desktop/src/shared/components/path-chooser/path-chooser.component.html @@ -0,0 +1,8 @@ +
+ + + + + +
\ No newline at end of file diff --git a/desktop/src/shared/components/path-chooser/path-chooser.component.scss b/desktop/src/shared/components/path-chooser/path-chooser.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/desktop/src/shared/components/path-chooser/path-chooser.component.ts b/desktop/src/shared/components/path-chooser/path-chooser.component.ts new file mode 100644 index 000000000..e9db349c2 --- /dev/null +++ b/desktop/src/shared/components/path-chooser/path-chooser.component.ts @@ -0,0 +1,53 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core' +import { ElectronService } from '../../services/electron.service' +import { dirname } from 'path' + +@Component({ + selector: 'neb-path-chooser', + templateUrl: './path-chooser.component.html', + styleUrls: ['./path-chooser.component.scss'], +}) +export class PathChooserComponent { + + @Input({ required: true }) + readonly key!: string + + @Input() + readonly label?: string + + @Input() + readonly placeholder?: string + + @Input() + readonly disabled: boolean = false + + @Input() + readonly readonly: boolean = false + + @Input({ required: true }) + readonly directory!: boolean + + @Input() + path?: string + + @Output() + readonly pathChange = new EventEmitter() + + constructor(private electron: ElectronService) { } + + async choosePath() { + const storageKey = `pathChooser.${this.key}.defaultPath` + const defaultPath = localStorage.getItem(storageKey) + const dirName = defaultPath && !this.directory ? dirname(defaultPath) : defaultPath + + const path = await (this.directory + ? this.electron.openDirectory({ defaultPath: dirName || this.path }) + : this.electron.openFile({ defaultPath: dirName || this.path })) + + if (path) { + this.path = path + this.pathChange.emit(path) + localStorage.setItem(storageKey, path) + } + } +} \ No newline at end of file diff --git a/desktop/src/shared/types/camera.types.ts b/desktop/src/shared/types/camera.types.ts index 5d48be72f..eb2c44edb 100644 --- a/desktop/src/shared/types/camera.types.ts +++ b/desktop/src/shared/types/camera.types.ts @@ -13,6 +13,8 @@ export type AutoSubFolderMode = 'OFF' | 'NOON' | 'MIDNIGHT' export type ExposureMode = 'SINGLE' | 'FIXED' | 'LOOP' +export type LiveStackerType = 'SIRIL' + export enum ExposureTimeUnit { MINUTE = 'm', SECOND = 's', @@ -20,6 +22,14 @@ export enum ExposureTimeUnit { MICROSECOND = 'µs', } +export function isCamera(device?: Device): device is Camera { + return !!device && 'exposuring' in device +} + +export function isGuideHead(device?: Device): device is GuideHead { + return isCamera(device) && isCompanionDevice(device) && !!device.main +} + export interface Camera extends GuideOutput, Thermometer { exposuring: boolean hasCoolerControl: boolean @@ -154,11 +164,12 @@ export interface CameraStartCapture { autoSave: boolean savePath?: string autoSubFolderMode: AutoSubFolderMode - dither?: Dither + dither: Dither filterPosition?: number shutterPosition?: number focusOffset?: number calibrationGroup?: string + liveStacking: LiveStackingRequest } export const EMPTY_CAMERA_START_CAPTURE: CameraStartCapture = { @@ -181,6 +192,13 @@ export const EMPTY_CAMERA_START_CAPTURE: CameraStartCapture = { afterExposures: 1, amount: 1.5, raOnly: false, + }, + liveStacking: { + enabled: false, + type: 'SIRIL', + executablePath: "", + rotate: 0, + use32Bits: false, } } @@ -210,7 +228,8 @@ export interface CameraCaptureEvent extends MessageEvent { stepElapsedTime: number stepProgress: number stepRemainingTime: number - savePath?: string + savedPath?: string + liveStackedSavedPath?: string state: CameraCaptureState } @@ -267,10 +286,12 @@ export const EMPTY_CAMERA_CAPTURE_INFO: CameraCaptureInfo = { count: 0, } -export function isCamera(device?: Device): device is Camera { - return !!device && 'exposuring' in device -} - -export function isGuideHead(device?: Device): device is GuideHead { - return isCamera(device) && isCompanionDevice(device) && !!device.main +export interface LiveStackingRequest { + enabled: boolean, + type: LiveStackerType, + executablePath: string, + dark?: string, + flat?: string, + rotate: number, + use32Bits: boolean, } diff --git a/desktop/src/shared/types/image.types.ts b/desktop/src/shared/types/image.types.ts index 2dabd6ef4..06346d2c0 100644 --- a/desktop/src/shared/types/image.types.ts +++ b/desktop/src/shared/types/image.types.ts @@ -128,6 +128,7 @@ export const EMPTY_IMAGE_PREFERENCE: ImagePreference = { export interface ImageData { camera?: Camera path?: string + liveStackedPath?: string source?: ImageSource title?: string capture?: CameraStartCapture diff --git a/desktop/src/styles.scss b/desktop/src/styles.scss index e2df7238b..6e2ddcc68 100644 --- a/desktop/src/styles.scss +++ b/desktop/src/styles.scss @@ -238,9 +238,11 @@ p-dropdownitem *, } .p-dialog { - .p-dialog-content { + &:has(.p-dialog-footer) .p-dialog-content { padding-bottom: 0px; + } + .p-dialog-content { &:has(neb-slide-menu) { background: #1e1e1e; color: rgba(255, 255, 255, 0.87); diff --git a/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt b/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt index 02d971acc..f1265268d 100644 --- a/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt +++ b/nebulosa-astap/src/main/kotlin/nebulosa/astap/star/detection/AstapStarDetector.kt @@ -30,9 +30,9 @@ data class AstapStarDetector( try { cmd.start() - LOG.info("astap exited. code={}", cmd.get()) } catch (e: Throwable) { + LOG.error("astap failed", e) return emptyList() } diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLine.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLine.kt index 062c9cd02..f27eebdb6 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLine.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/exec/CommandLine.kt @@ -28,6 +28,9 @@ data class CommandLine internal constructor( val command: List get() = builder.command() + val pid + get() = process?.pid() ?: -1L + val writer = PrintStream(object : OutputStream() { override fun write(b: Int) { diff --git a/nebulosa-siril/src/main/kotlin/nebulosa/siril/livestacking/SirilLiveStacker.kt b/nebulosa-siril/src/main/kotlin/nebulosa/siril/livestacking/SirilLiveStacker.kt index 0b6f7d4f2..af8907462 100644 --- a/nebulosa-siril/src/main/kotlin/nebulosa/siril/livestacking/SirilLiveStacker.kt +++ b/nebulosa-siril/src/main/kotlin/nebulosa/siril/livestacking/SirilLiveStacker.kt @@ -11,6 +11,7 @@ import nebulosa.math.Angle import java.nio.file.Path import java.util.concurrent.atomic.AtomicBoolean import kotlin.io.path.deleteIfExists +import kotlin.io.path.isSymbolicLink import kotlin.io.path.listDirectoryEntries import kotlin.io.path.name @@ -23,36 +24,38 @@ data class SirilLiveStacker( private val use32Bits: Boolean = false, ) : LiveStacker, LineReadListener { - @Volatile private var process: CommandLine? = null + @Volatile private var commandLine: CommandLine? = null private val waitForStacking = CountUpDownLatch() private val failed = AtomicBoolean() override val isRunning - get() = process != null && !process!!.isDone + get() = commandLine != null && !commandLine!!.isDone override val isStacking get() = !waitForStacking.get() @Synchronized override fun start() { - if (process == null) { - process = commandLine { + if (commandLine == null) { + commandLine = commandLine { executablePath(executablePath) putArg("-s", "-") registerLineReadListener(this@SirilLiveStacker) } - process!!.whenComplete { _, e -> - println("completed. $e") - process = null + commandLine!!.whenComplete { exitCode, e -> + LOG.info("live stacking finished. exitCode={}", exitCode, e) + commandLine = null } - process!!.start() + commandLine!!.start() - process!!.writer.println(REQUIRES_COMMAND) - process!!.writer.println("$CD_COMMAND $workingDirectory") - process!!.writer.println(buildString(256) { + LOG.info("live stacking started. pid={}", commandLine!!.pid) + + commandLine!!.writer.println(REQUIRES_COMMAND) + commandLine!!.writer.println("$CD_COMMAND $workingDirectory") + commandLine!!.writer.println(buildString(256) { append(START_LS_COMMAND) if (dark != null) append(" \"-dark=$dark\"") if (flat != null) append(" \"-flat=$flat\"") @@ -65,25 +68,32 @@ data class SirilLiveStacker( @Synchronized override fun add(path: Path): Path? { failed.set(false) - waitForStacking.countUp() - process?.writer?.println("$LS_COMMAND $path") - waitForStacking.await() - return if (failed.get()) null else Path.of("$workingDirectory", "live_stack_00001.fit") + try { + waitForStacking.countUp() + commandLine?.writer?.println("$LS_COMMAND \"$path\"") + waitForStacking.await() + } catch (e: Throwable) { + LOG.error("failed to add path", e) + return null + } + + return if (failed.get()) null + else Path.of("$workingDirectory", "live_stack_00001.fit") } @Synchronized override fun stop() { waitForStacking.reset() - process?.writer?.println(STOP_LS_COMMAND) - process?.stop() - process = null + commandLine?.writer?.println(STOP_LS_COMMAND) + commandLine?.stop() + commandLine = null } override fun close() { stop() - workingDirectory.clearStackingFiles() + workingDirectory.deleteStackingFiles() } override fun onInputRead(line: String) { @@ -127,13 +137,15 @@ data class SirilLiveStacker( @JvmStatic private val LIVE_STACK_SEQ_REGEX = Regex("live_stack_\\d*.seq") @JvmStatic - private fun Path.clearStackingFiles() { - for (file in listDirectoryEntries("*")) { - val name = file.name + fun Path.deleteStackingFiles() { + for (file in listDirectoryEntries("*.fit")) { + if (file.isSymbolicLink() && LIVE_STACK_FIT_REGEX.matches(file.name)) { + file.deleteIfExists() + } + } - if (LIVE_STACK_FIT_REGEX.matches(name) || - LIVE_STACK_SEQ_REGEX.matches(name) - ) { + for (file in listDirectoryEntries("*.seq")) { + if (LIVE_STACK_SEQ_REGEX.matches(file.name)) { file.deleteIfExists() } } From 3dd79de6081bdb18acbd11f63f9d3bd5d088b347 Mon Sep 17 00:00:00 2001 From: tiagohm Date: Sun, 2 Jun 2024 20:46:14 -0300 Subject: [PATCH 4/6] [api][desktop]: Improve Settings layout --- .../alignment/polar/tppa/TPPAStartRequest.kt | 4 +- .../api/autofocus/AutoFocusRequest.kt | 4 +- .../api/solver/PlateSolverController.kt | 2 +- ...SolverOptions.kt => PlateSolverRequest.kt} | 4 +- .../nebulosa/api/solver/PlateSolverService.kt | 4 +- .../stardetection/StarDetectionController.kt | 2 +- ...tionOptions.kt => StarDetectionRequest.kt} | 4 +- .../api/stardetection/StarDetectionService.kt | 2 +- api/src/test/kotlin/APITest.kt | 4 +- desktop/package.json | 2 +- desktop/settings.png | Bin 13205 -> 12562 bytes .../src/app/alignment/alignment.component.ts | 2 +- .../src/app/autofocus/autofocus.component.ts | 2 +- desktop/src/app/camera/camera.component.html | 5 - desktop/src/app/camera/camera.component.ts | 2 + desktop/src/app/image/image.component.ts | 6 +- .../src/app/settings/settings.component.html | 140 +++++++++++------- .../src/app/settings/settings.component.ts | 68 +++++---- .../shared/components/map/map.component.html | 2 +- .../path-chooser/path-chooser.component.html | 3 +- .../path-chooser/path-chooser.component.ts | 12 +- .../dialogs/location/location.dialog.html | 26 ++-- .../shared/services/browser-window.service.ts | 4 +- .../src/shared/services/preference.service.ts | 16 +- desktop/src/shared/types/camera.types.ts | 8 + 25 files changed, 192 insertions(+), 136 deletions(-) rename api/src/main/kotlin/nebulosa/api/solver/{PlateSolverOptions.kt => PlateSolverRequest.kt} (95%) rename api/src/main/kotlin/nebulosa/api/stardetection/{StarDetectionOptions.kt => StarDetectionRequest.kt} (87%) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStartRequest.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStartRequest.kt index acb655885..066e09151 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStartRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStartRequest.kt @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties import jakarta.validation.Valid import jakarta.validation.constraints.NotNull import nebulosa.api.cameras.CameraStartCaptureRequest -import nebulosa.api.solver.PlateSolverOptions +import nebulosa.api.solver.PlateSolverRequest import nebulosa.guiding.GuideDirection import org.hibernate.validator.constraints.time.DurationMin import org.springframework.boot.convert.DurationUnit @@ -13,7 +13,7 @@ import java.time.temporal.ChronoUnit data class TPPAStartRequest( @JsonIgnoreProperties("camera", "focuser", "wheel") @JvmField val capture: CameraStartCaptureRequest = CameraStartCaptureRequest.EMPTY, - @field:NotNull @Valid @JvmField val plateSolver: PlateSolverOptions = PlateSolverOptions.EMPTY, + @field:NotNull @Valid @JvmField val plateSolver: PlateSolverRequest = PlateSolverRequest.EMPTY, @JvmField val startFromCurrentPosition: Boolean = true, @JvmField val compensateRefraction: Boolean = false, @JvmField val stopTrackingWhenDone: Boolean = true, diff --git a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusRequest.kt b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusRequest.kt index 5f1c5edd1..2a30afc6c 100644 --- a/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusRequest.kt @@ -2,7 +2,7 @@ package nebulosa.api.autofocus import nebulosa.api.cameras.CameraStartCaptureRequest import nebulosa.api.focusers.BacklashCompensation -import nebulosa.api.stardetection.StarDetectionOptions +import nebulosa.api.stardetection.StarDetectionRequest data class AutoFocusRequest( @JvmField val fittingMode: AutoFocusFittingMode = AutoFocusFittingMode.HYPERBOLIC, @@ -12,5 +12,5 @@ data class AutoFocusRequest( @JvmField val initialOffsetSteps: Int = 4, @JvmField val stepSize: Int = 50, @JvmField val totalNumberOfAttempts: Int = 1, - @JvmField val starDetector: StarDetectionOptions = StarDetectionOptions.EMPTY, + @JvmField val starDetector: StarDetectionRequest = StarDetectionRequest.EMPTY, ) diff --git a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverController.kt b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverController.kt index f4936f0fa..cb828bb33 100644 --- a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverController.kt +++ b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverController.kt @@ -15,7 +15,7 @@ class PlateSolverController( @PutMapping fun solveImage( @RequestParam path: Path, - @RequestBody @Valid solver: PlateSolverOptions, + @RequestBody @Valid solver: PlateSolverRequest, @RequestParam(required = false, defaultValue = "true") blind: Boolean, @AngleParam(required = false, isHours = true, defaultValue = "0.0") centerRA: Angle, @AngleParam(required = false, defaultValue = "0.0") centerDEC: Angle, diff --git a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverRequest.kt similarity index 95% rename from api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt rename to api/src/main/kotlin/nebulosa/api/solver/PlateSolverRequest.kt index 1612c43cc..724fc06ad 100644 --- a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt +++ b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverRequest.kt @@ -12,7 +12,7 @@ import java.nio.file.Path import java.time.Duration import java.time.temporal.ChronoUnit -data class PlateSolverOptions( +data class PlateSolverRequest( @JvmField val type: PlateSolverType = PlateSolverType.ASTROMETRY_NET_ONLINE, @JvmField val executablePath: Path? = null, @JvmField val downsampleFactor: Int = 0, @@ -36,7 +36,7 @@ data class PlateSolverOptions( companion object { - @JvmStatic val EMPTY = PlateSolverOptions() + @JvmStatic val EMPTY = PlateSolverRequest() @JvmStatic private val NOVA_ASTROMETRY_NET_CACHE = HashMap() } } diff --git a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt index 61f0e0790..9564562b9 100644 --- a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt +++ b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverService.kt @@ -14,7 +14,7 @@ class PlateSolverService( ) { fun solveImage( - options: PlateSolverOptions, path: Path, + options: PlateSolverRequest, path: Path, centerRA: Angle, centerDEC: Angle, radius: Angle, ): ImageSolved { val calibration = solve(options, path, centerRA, centerDEC, radius) @@ -24,7 +24,7 @@ class PlateSolverService( @Synchronized fun solve( - options: PlateSolverOptions, path: Path, + options: PlateSolverRequest, path: Path, centerRA: Angle = 0.0, centerDEC: Angle = 0.0, radius: Angle = 0.0, ) = options.get(httpClient) .solve(path, null, centerRA, centerDEC, radius, 1, options.timeout.takeIf { it.toSeconds() > 0 }) diff --git a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionController.kt b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionController.kt index 810c13811..07868c583 100644 --- a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionController.kt +++ b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionController.kt @@ -13,6 +13,6 @@ class StarDetectionController(private val starDetectionService: StarDetectionSer @PutMapping fun detectStars( @RequestParam path: Path, - @RequestBody @Valid body: StarDetectionOptions + @RequestBody @Valid body: StarDetectionRequest ) = starDetectionService.detectStars(path, body) } diff --git a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionOptions.kt b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionRequest.kt similarity index 87% rename from api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionOptions.kt rename to api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionRequest.kt index 9d3711755..dcdd0ab97 100644 --- a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionOptions.kt +++ b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionRequest.kt @@ -6,7 +6,7 @@ import java.nio.file.Path import java.time.Duration import java.util.function.Supplier -data class StarDetectionOptions( +data class StarDetectionRequest( @JvmField val type: StarDetectorType = StarDetectorType.ASTAP, @JvmField val executablePath: Path? = null, @JvmField val timeout: Duration = Duration.ZERO, @@ -19,6 +19,6 @@ data class StarDetectionOptions( companion object { - @JvmStatic val EMPTY = StarDetectionOptions() + @JvmStatic val EMPTY = StarDetectionRequest() } } diff --git a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionService.kt b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionService.kt index 243239456..b1ee07583 100644 --- a/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionService.kt +++ b/api/src/main/kotlin/nebulosa/api/stardetection/StarDetectionService.kt @@ -7,7 +7,7 @@ import java.nio.file.Path @Service class StarDetectionService { - fun detectStars(path: Path, options: StarDetectionOptions): List { + fun detectStars(path: Path, options: StarDetectionRequest): List { val starDetector = options.get() return starDetector.detect(path) } diff --git a/api/src/test/kotlin/APITest.kt b/api/src/test/kotlin/APITest.kt index a71b9c688..21e0d8fee 100644 --- a/api/src/test/kotlin/APITest.kt +++ b/api/src/test/kotlin/APITest.kt @@ -9,7 +9,7 @@ import nebulosa.api.autofocus.AutoFocusRequest import nebulosa.api.beans.converters.time.DurationSerializer import nebulosa.api.cameras.CameraStartCaptureRequest import nebulosa.api.connection.ConnectionType -import nebulosa.api.stardetection.StarDetectionOptions +import nebulosa.api.stardetection.StarDetectionRequest import nebulosa.common.json.PathSerializer import nebulosa.test.NonGitHubOnlyCondition import okhttp3.MediaType.Companion.toMediaType @@ -169,7 +169,7 @@ class APITest : StringSpec() { @JvmStatic private val EXPOSURE_TIME = Duration.ofSeconds(5) @JvmStatic private val CAPTURES_PATH = Path.of("/home/tiagohm/Git/nebulosa/data/captures") - @JvmStatic private val STAR_DETECTION_OPTIONS = StarDetectionOptions(executablePath = Path.of("astap")) + @JvmStatic private val STAR_DETECTION_OPTIONS = StarDetectionRequest(executablePath = Path.of("astap")) @JvmStatic private val CAMERA_START_CAPTURE_REQUEST = CameraStartCaptureRequest( exposureTime = EXPOSURE_TIME, width = 1280, height = 1024, frameFormat = "INDI_MONO", diff --git a/desktop/package.json b/desktop/package.json index 0466564d7..4bd615f44 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -14,7 +14,7 @@ "postinstall": "electron-builder install-app-deps", "ng": "ng", "start": "npm-run-all -p electron:serve ng:serve", - "ng:serve": "ng serve -c web", + "ng:serve": "ng serve -c web --hmr", "build": "npm run electron:serve-tsc && ng build --base-href ./", "build:dev": "npm run build -- -c dev", "build:prod": "npm run build -- -c production", diff --git a/desktop/settings.png b/desktop/settings.png index ee6099d48c083968b25954a0b02888d6549f8d19..80e5fde642478de703531dbe1462121adc910781 100644 GIT binary patch literal 12562 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 literal 13205 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 (°) -
- -
diff --git a/desktop/src/app/camera/camera.component.ts b/desktop/src/app/camera/camera.component.ts index c568ae3ad..de5d81f1d 100644 --- a/desktop/src/app/camera/camera.component.ts +++ b/desktop/src/app/camera/camera.component.ts @@ -532,6 +532,8 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { const exposureAmount = this.exposureMode === 'LOOP' ? 0 : (this.exposureMode === 'FIXED' ? this.request.exposureAmount : 1) const savePath = this.mode !== 'CAPTURE' ? this.request.savePath : this.savePath + this.request.liveStacking.executablePath = this.preference.liveStackingRequest(this.request.liveStacking.type).get().executablePath + return { ...this.request, x, y, width, height, diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 1c5c4af80..672c3da7f 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -775,7 +775,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } async detectStars() { - const options = this.preference.starDetectionOptions(this.starDetection.type).get() + const options = this.preference.starDetectionRequest(this.starDetection.type).get() options.minSNR = this.starDetection.minSNR this.starDetection.stars = await this.api.detectStars(this.imagePath!, options) @@ -1033,7 +1033,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.solver.solving = true try { - const solver = this.preference.plateSolverOptions(this.solver.type).get() + const solver = this.preference.plateSolverRequest(this.solver.type).get() const solved = await this.api.solveImage(solver, this.imagePath!, this.solver.blind, this.solver.centerRA, this.solver.centerDEC, this.solver.radius) @@ -1221,7 +1221,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.solver.radius = preference.solverRadius ?? this.solver.radius this.solver.type = preference.solverType ?? this.solver.types[0] this.starDetection.type = preference.starDetectionType ?? this.starDetection.type - this.starDetection.minSNR = this.preference.starDetectionOptions(this.starDetection.type).get().minSNR ?? this.starDetection.type + this.starDetection.minSNR = this.preference.starDetectionRequest(this.starDetection.type).get().minSNR ?? this.starDetection.type this.fov.fovs = this.preference.imageFOVs.get() this.fov.fovs.forEach(e => { e.enabled = false; e.computed = undefined }) diff --git a/desktop/src/app/settings/settings.component.html b/desktop/src/app/settings/settings.component.html index e2cdc4107..e7f18020c 100644 --- a/desktop/src/app/settings/settings.component.html +++ b/desktop/src/app/settings/settings.component.html @@ -1,90 +1,126 @@ -
- - -
- +
+
+ + + + +
+
+
+
+
- - - + + +
- - -
- - - +
+
+
+
+
+ + + - +
+
+ - - +
+ + - - - - - - - - - +
+
+ +
+
+ + - - +
+
+ +
- - -
- +
+
+
+
+
+ - + - +
+
+ - +
+
+ - - - - - - +
+
+ +
+
+
+
+
+
+ + +
- - +
+ +
+
+
\ No newline at end of file diff --git a/desktop/src/app/settings/settings.component.ts b/desktop/src/app/settings/settings.component.ts index 2d8931004..dfd22b51c 100644 --- a/desktop/src/app/settings/settings.component.ts +++ b/desktop/src/app/settings/settings.component.ts @@ -1,11 +1,10 @@ import { AfterViewInit, Component, HostListener, OnDestroy } from '@angular/core' -import path from 'path' import { LocationDialog } from '../../shared/dialogs/location/location.dialog' -import { ApiService } from '../../shared/services/api.service' import { ElectronService } from '../../shared/services/electron.service' import { PreferenceService } from '../../shared/services/preference.service' import { PrimeService } from '../../shared/services/prime.service' import { EMPTY_LOCATION, Location } from '../../shared/types/atlas.types' +import { LiveStackerType, LiveStackingRequest } from '../../shared/types/camera.types' import { PlateSolverOptions, PlateSolverType, StarDetectionOptions, StarDetectorType } from '../../shared/types/settings.types' import { AppComponent } from '../app.component' @@ -16,18 +15,40 @@ import { AppComponent } from '../app.component' }) export class SettingsComponent implements AfterViewInit, OnDestroy { + tab = 0 + readonly tabs: { id: number, name: string }[] = [ + { + id: 0, + name: 'Location' + }, + { + id: 1, + name: 'Plate Solver' + }, + { + id: 2, + name: 'Star Detection' + }, + { + id: 3, + name: 'Live Stacking' + }, + ] + readonly locations: Location[] location: Location - solverType: PlateSolverType = 'ASTAP' - readonly solvers = new Map() + plateSolverType: PlateSolverType = 'ASTAP' + readonly plateSolvers = new Map() starDetectorType: StarDetectorType = 'ASTAP' readonly starDetectors = new Map() + liveStackerType: LiveStackerType = 'SIRIL' + readonly liveStackers = new Map() + constructor( app: AppComponent, - private api: ApiService, private preference: PreferenceService, private electron: ElectronService, private prime: PrimeService, @@ -37,10 +58,12 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { this.locations = preference.locations.get() this.location = preference.selectedLocation.get(this.locations[0]) - this.solvers.set('ASTAP', preference.plateSolverOptions('ASTAP').get()) - this.solvers.set('ASTROMETRY_NET_ONLINE', preference.plateSolverOptions('ASTROMETRY_NET_ONLINE').get()) + this.plateSolvers.set('ASTAP', preference.plateSolverRequest('ASTAP').get()) + this.plateSolvers.set('ASTROMETRY_NET_ONLINE', preference.plateSolverRequest('ASTROMETRY_NET_ONLINE').get()) - this.starDetectors.set('ASTAP', preference.starDetectionOptions('ASTAP').get()) + this.starDetectors.set('ASTAP', preference.starDetectionRequest('ASTAP').get()) + + this.liveStackers.set('SIRIL', preference.liveStackingRequest('SIRIL').get()) } async ngAfterViewInit() { } @@ -102,31 +125,12 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { this.electron.send('LOCATION.CHANGED', this.location) } - async chooseExecutablePathForPlateSolver() { - const options = this.solvers.get(this.solverType)! - this.chooseExecutablePath(options) - } - - async chooseExecutablePathForStarDetection() { - const options = this.solvers.get(this.starDetectorType)! - this.chooseExecutablePath(options) - } - - private async chooseExecutablePath(options: { executablePath: string }) { - const executablePath = await this.electron.openFile({ defaultPath: path.dirname(options.executablePath) }) - - if (executablePath) { - options.executablePath = executablePath - this.save() - } - - return executablePath - } - save() { - this.preference.plateSolverOptions('ASTAP').set(this.solvers.get('ASTAP')!) - this.preference.plateSolverOptions('ASTROMETRY_NET_ONLINE').set(this.solvers.get('ASTROMETRY_NET_ONLINE')!) + this.preference.plateSolverRequest('ASTAP').set(this.plateSolvers.get('ASTAP')) + this.preference.plateSolverRequest('ASTROMETRY_NET_ONLINE').set(this.plateSolvers.get('ASTROMETRY_NET_ONLINE')) + + this.preference.starDetectionRequest('ASTAP').set(this.starDetectors.get('ASTAP')) - this.preference.starDetectionOptions('ASTAP').set(this.starDetectors.get('ASTAP')!) + this.preference.liveStackingRequest('SIRIL').set(this.liveStackers.get('SIRIL')) } } \ No newline at end of file diff --git a/desktop/src/shared/components/map/map.component.html b/desktop/src/shared/components/map/map.component.html index e4e6a7ec1..0c20a3260 100644 --- a/desktop/src/shared/components/map/map.component.html +++ b/desktop/src/shared/components/map/map.component.html @@ -1 +1 @@ -
+
diff --git a/desktop/src/shared/components/path-chooser/path-chooser.component.html b/desktop/src/shared/components/path-chooser/path-chooser.component.html index 562ddc331..f650816a8 100644 --- a/desktop/src/shared/components/path-chooser/path-chooser.component.html +++ b/desktop/src/shared/components/path-chooser/path-chooser.component.html @@ -4,5 +4,6 @@ [placeholder]="placeholder ?? ''" [ngModel]="path" (ngModelChange)="pathChange.emit($event)" /> - +
\ No newline at end of file diff --git a/desktop/src/shared/components/path-chooser/path-chooser.component.ts b/desktop/src/shared/components/path-chooser/path-chooser.component.ts index e9db349c2..086c5453e 100644 --- a/desktop/src/shared/components/path-chooser/path-chooser.component.ts +++ b/desktop/src/shared/components/path-chooser/path-chooser.component.ts @@ -1,13 +1,13 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core' -import { ElectronService } from '../../services/electron.service' +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core' import { dirname } from 'path' +import { ElectronService } from '../../services/electron.service' @Component({ selector: 'neb-path-chooser', templateUrl: './path-chooser.component.html', styleUrls: ['./path-chooser.component.scss'], }) -export class PathChooserComponent { +export class PathChooserComponent implements OnChanges { @Input({ required: true }) readonly key!: string @@ -35,6 +35,12 @@ export class PathChooserComponent { constructor(private electron: ElectronService) { } + ngOnChanges(changes: SimpleChanges) { + if (changes.path) { + this.path = changes.path.currentValue + } + } + async choosePath() { const storageKey = `pathChooser.${this.key}.defaultPath` const defaultPath = localStorage.getItem(storageKey) diff --git a/desktop/src/shared/dialogs/location/location.dialog.html b/desktop/src/shared/dialogs/location/location.dialog.html index 7fe83c726..21068ab49 100644 --- a/desktop/src/shared/dialogs/location/location.dialog.html +++ b/desktop/src/shared/dialogs/location/location.dialog.html @@ -1,42 +1,42 @@ -
-
+
+
-
+
-
+
+ + + + +
+
-
+
-
- - - - -
-