diff --git a/.github/workflows/angular.yml b/.github/workflows/angular.yml index 5b6d60d44..1dfa7a11e 100644 --- a/.github/workflows/angular.yml +++ b/.github/workflows/angular.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Install Dependencies run: npm i diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0cba58fc6..b1559268a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -52,7 +52,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Download API uses: actions/download-artifact@v4 diff --git a/README.md b/README.md index af52ae372..fee72c653 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Active Development](https://img.shields.io/badge/Maintenance%20Level-Actively%20Developed-brightgreen.svg)](https://gist.github.com/cheerfulstoic/d107229326a01ff0f333a1d3476e068d) [![CI](https://github.com/tiagohm/nebulosa/actions/workflows/ci.yml/badge.svg)](https://github.com/tiagohm/nebulosa/actions/workflows/ci.yml) -[![CodeFactor](https://www.codefactor.io/repository/github/tiagohm/nebulosa/badge/main)](https://www.codefactor.io/repository/github/tiagohm/nebulosa/overview/main) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/62f7820784d142dab9feebc222cba4a8)](https://app.codacy.com/gh/tiagohm/nebulosa/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) The complete integrated solution for all of your astronomical imaging needs. @@ -15,7 +15,29 @@ The complete integrated solution for all of your astronomical imaging needs. ### Steps -* `./gradlew api:bootJar` -* `cd desktop` -* `npm i` -* `npm run electron:build` +1. `./gradlew api:bootJar` +2. `cd desktop` +3. `npm i` + +#### On Linux + +4. `npm run electron:build:deb` to build `.deb` package. +5. `npm run electron:build:app` to build `AppImage`. +6. `npm run electron:build:rpm` to build `RPM` package. + +Before build a `RPM` package, run `sudo apt install rpm`. + +#### On Windows + +4. `npm run electron:build` to build the `.exe`. + +#### On Linux ARM (Raspberry PI) + +run these commands before: + +* `sudo apt install ruby ruby-dev` +* `sudo gem install fpm` + +4. `USE_SYSTEM_FPM=true npm run electron:build:deb` to build `.deb` package. + +> Look at `release` subdirectory for the generated build. diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 281255791..8832af464 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -2,8 +2,8 @@ import org.springframework.boot.gradle.tasks.bundling.BootJar plugins { kotlin("jvm") - id("org.springframework.boot") version "3.2.4" - id("io.spring.dependency-management") version "1.1.4" + id("org.springframework.boot") version "3.2.5" + id("io.spring.dependency-management") version "1.1.5" kotlin("plugin.spring") kotlin("kapt") id("io.objectbox") @@ -14,7 +14,6 @@ dependencies { implementation(project(":nebulosa-astap")) implementation(project(":nebulosa-astrometrynet")) implementation(project(":nebulosa-alpaca-indi")) - implementation(project(":nebulosa-batch-processing")) implementation(project(":nebulosa-common")) implementation(project(":nebulosa-guiding-phd2")) implementation(project(":nebulosa-hips2fits")) @@ -30,6 +29,7 @@ dependencies { implementation(project(":nebulosa-watney")) implementation(project(":nebulosa-wcs")) implementation(project(":nebulosa-xisf")) + implementation(libs.rx) implementation(libs.apache.codec) implementation(libs.csv) implementation(libs.eventbus) @@ -46,7 +46,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-undertow") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") - kapt("org.springframework:spring-context-indexer:6.1.5") + kapt("org.springframework:spring-context-indexer:6.1.7") testImplementation(project(":nebulosa-astrobin-api")) testImplementation(project(":nebulosa-skycatalog-stellarium")) testImplementation(project(":nebulosa-test")) diff --git a/api/schemas/objectbox.json b/api/schemas/objectbox.json index a3669305b..811401385 100644 --- a/api/schemas/objectbox.json +++ b/api/schemas/objectbox.json @@ -4,81 +4,77 @@ "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", "entities": [ { - "id": "1:8996632906249699640", - "lastPropertyId": "13:4967132491159177650", + "id": "1:4508028933515523414", + "lastPropertyId": "13:5569629325911720184", "name": "CalibrationFrameEntity", "properties": [ { - "id": "1:8753104016604228424", + "id": "1:279471804400581871", "name": "id", "type": 6, "flags": 1 }, { - "id": "2:5256997905701910721", + "id": "2:9048727858630632737", "name": "type", - "indexId": "1:6629699679938480062", + "indexId": "1:3018423918314968566", "type": 5, "flags": 8 }, { - "id": "3:4250539975904011338", - "name": "camera", - "indexId": "2:8670761666150034104", + "id": "3:5712791023807889534", + "name": "name", + "indexId": "2:8432810603549739468", "type": 9, "flags": 2048 }, { - "id": "4:2964126873299902795", + "id": "4:3434117744352502900", "name": "filter", - "indexId": "3:356806529647771872", - "type": 9, - "flags": 2048 + "type": 9 }, { - "id": "5:6592821044234392872", + "id": "5:1871034143652415809", "name": "exposureTime", "type": 6 }, { - "id": "6:7841731947961734124", + "id": "6:8846123268014704509", "name": "temperature", "type": 8 }, { - "id": "7:8769358817044866175", + "id": "7:8561154143050278063", "name": "width", "type": 5 }, { - "id": "8:9066846022258802237", + "id": "8:6920579444153489022", "name": "height", "type": 5 }, { - "id": "9:6089591003549470592", + "id": "9:4300769060778976734", "name": "binX", "type": 5 }, { - "id": "10:1137944585964286888", + "id": "10:4693474237106002327", "name": "binY", "type": 5 }, { - "id": "11:75379120605439289", + "id": "11:8369728096653684761", "name": "gain", "type": 8 }, { - "id": "12:5234537218429949481", + "id": "12:617052828938607363", "name": "path", - "indexId": "4:1409612170773192505", - "type": 9, - "flags": 2080 + "type": 9 }, { - "id": "13:4967132491159177650", + "id": "13:5569629325911720184", "name": "enabled", "type": 1 } @@ -86,25 +82,25 @@ "relations": [] }, { - "id": "2:2865903592621070186", - "lastPropertyId": "3:4231519923961266979", + "id": "2:4800249862026080527", + "lastPropertyId": "3:211299529025119304", "name": "PreferenceEntity", "properties": [ { - "id": "1:6437649959717305879", + "id": "1:3593540058272630983", "name": "id", "type": 6, "flags": 1 }, { - "id": "2:3424721012607374437", + "id": "2:2699303611424729430", "name": "key", - "indexId": "5:3959175721933196667", + "indexId": "3:2030544424571300028", "type": 9, "flags": 34848 }, { - "id": "3:4231519923961266979", + "id": "3:211299529025119304", "name": "value", "type": 9 } @@ -112,28 +108,28 @@ "relations": [] }, { - "id": "3:7591326030822090985", - "lastPropertyId": "4:960749109935496711", + "id": "3:9190695617085753667", + "lastPropertyId": "4:411434182698925224", "name": "SatelliteEntity", "properties": [ { - "id": "1:8984524405419355357", + "id": "1:7748265871438465999", "name": "id", "type": 6, "flags": 129 }, { - "id": "2:954886680255875455", + "id": "2:2980713713220488130", "name": "name", "type": 9 }, { - "id": "3:2209800562197387575", + "id": "3:8036745814034214740", "name": "tle", "type": 9 }, { - "id": "4:960749109935496711", + "id": "4:411434182698925224", "name": "groups", "type": 30 } @@ -141,68 +137,68 @@ "relations": [] }, { - "id": "4:1799461706223884117", - "lastPropertyId": "12:1425111719526355640", + "id": "4:6299583728620001761", + "lastPropertyId": "12:4179508964623201115", "name": "SimbadEntity", "properties": [ { - "id": "1:8675363815775912010", + "id": "1:7284883107181783588", "name": "id", "type": 6, "flags": 129 }, { - "id": "2:9134078346895015096", + "id": "2:1059978401562504177", "name": "name", "type": 9 }, { - "id": "3:5687549887167089900", + "id": "3:2238737597611607433", "name": "type", "type": 5 }, { - "id": "4:425502224150658936", + "id": "4:6034348124979703831", "name": "rightAscensionJ2000", "type": 8 }, { - "id": "5:2116112963954541453", + "id": "5:6603670815168137185", "name": "declinationJ2000", "type": 8 }, { - "id": "6:3944877011180871841", + "id": "6:4798847469480514750", "name": "magnitude", "type": 8 }, { - "id": "7:7502412627527780934", + "id": "7:4280564484498302769", "name": "pmRA", "type": 8 }, { - "id": "8:2354824452575366292", + "id": "8:1070997648386390650", "name": "pmDEC", "type": 8 }, { - "id": "9:266214111788703179", + "id": "9:7408560810497672822", "name": "parallax", "type": 8 }, { - "id": "10:3283290633306994233", + "id": "10:7464931444484734827", "name": "radialVelocity", "type": 8 }, { - "id": "11:2537767302764251008", + "id": "11:531497562996887037", "name": "redshift", "type": 8 }, { - "id": "12:1425111719526355640", + "id": "12:4179508964623201115", "name": "constellation", "type": 5 } @@ -210,8 +206,8 @@ "relations": [] } ], - "lastEntityId": "4:1799461706223884117", - "lastIndexId": "5:3959175721933196667", + "lastEntityId": "4:6299583728620001761", + "lastIndexId": "3:2030544424571300028", "lastRelationId": "0:0", "lastSequenceId": "0:0", "modelVersion": 5, diff --git a/api/src/main/kotlin/nebulosa/api/README.md b/api/src/main/kotlin/nebulosa/api/README.md deleted file mode 100644 index c4f03ed8a..000000000 --- a/api/src/main/kotlin/nebulosa/api/README.md +++ /dev/null @@ -1,264 +0,0 @@ -# Nebulosa API - -## Web Socket Events - -URL: `localhost:{PORT}/ws` - -### Camera - -#### CAMERA.UPDATED, CAMERA.ATTACHED, CAMERA.DETACHED - -```json5 -{ - "device": { - "exposuring": false, - "hasCoolerControl": false, - "coolerPower": 0, - "cooler": false, - "hasDewHeater": false, - "dewHeater": false, - "frameFormats": [], - "canAbort": false, - "cfaOffsetX": 0, - "cfaOffsetY": 0, - "cfaType": "RGGB", - "exposureMin": 0, - "exposureMax": 1, - "exposureState": "IDLE", - "exposureTime": 1, - "hasCooler": false, - "canSetTemperature": false, - "canSubFrame": false, - "x": 0, - "minX": 0, - "maxX": 0, - "y": 0, - "minY": 0, - "maxY": 0, - "width": 1023, - "minWidth": 1023, - "maxWidth": 1023, - "height": 1280, - "minHeight": 1280, - "maxHeight": 1280, - "canBin": false, - "maxBinX": 1, - "maxBinY": 1, - "binX": 1, - "binY": 1, - "gain": 0, - "gainMin": 0, - "gainMax": 0, - "offset": 0, - "offsetMin": 0, - "offsetMax": 0, - "hasGuideHead": false, - "pixelSizeX": 0, - "pixelSizeY": 0, - "capturesPath": "", - "canPulseGuide": false, - "pulseGuiding": false, - "name": "", - "connected": false, - "hasThermometer": false, - "temperature": 0 - } -} -``` - -#### CAMERA.CAPTURE_ELAPSED - -```json5 -{ - "camera": {}, - "state": "CAPTURE_STARTED|EXPOSURE_STARTED|EXPOSURING|WAITING|SETTLING|EXPOSURE_FINISHED|CAPTURE_FINISHED", - "exposureAmount": 0, - "exposureCount": 0, - "captureElapsedTime": 0, - "captureProgress": 0.0, - "captureRemainingTime": 0, - "exposureProgress": 0, - "exposureRemainingTime": 0.0, - "waitRemainingTime": 0, - "waitProgress": 0.0, - "savePath": "", -} -``` - -### Mount - -#### MOUNT.UPDATED, MOUNT.ATTACHED, MOUNT.DETACHED - -```json5 -{ - "device": { - "slewing": false, - "tracking": false, - "canAbort": false, - "canSync": false, - "canGoTo": false, - "canHome": false, - "slewRates": [], - "trackModes": [], - "trackMode": "SIDEREAL", - "pierSide": "NEITHER", - "guideRateWE": 0, - "guideRateNS": 0, - "rightAscension": "00h00m00s", - "declination": "00°00\"00", - "hasGPS": false, - "longitude": 0, - "latitude": 0, - "elevation": 0, - "dateTime": 0, - "offsetInMinutes": 0, - "name": "", - "connected": false, - "canPulseGuide": false, - "pulseGuiding": false, - "canPark": false, - "parking": false, - "parked": false - } -} -``` - -### Filter Wheel - -#### WHEEL.UPDATED, WHEEL.ATTACHED, WHEEL.DETACHED - -```json5 -{ - "device": { - "count": 0, - "position": 0, - "moving": false, - "name": "", - "connected": false - } -} -``` - -### Focuser - -#### FOCUSER.UPDATED, FOCUSER.ATTACHED, FOCUSER.DETACHED - -```json5 -{ - "device": { - "moving": false, - "position": 0, - "canAbsoluteMove": false, - "canRelativeMove": false, - "canAbort": false, - "canReverse": false, - "reversed": false, - "canSync": false, - "hasBacklash": false, - "maxPosition": 0, - "name": "", - "connected": false, - "hasThermometer": false, - "temperature": 0 - } -} -``` - -### Guide Output - -#### GUIDE_OUTPUT.UPDATED, GUIDE_OUTPUT.ATTACHED, GUIDE_OUTPUT.DETACHED - -```json5 -{ - "device": { - "canPulseGuide": false, - "pulseGuiding": false, - "name": "", - "connected": false - } -} -``` - -### DARV Polar Alignment - -#### DARV.ELAPSED - -```json5 -{ - "id": "", - "remainingTime": 0, - "progress": 0.0, - "direction": "EAST", - "state": "FORWARD|BACKWARD" -} -``` - -### Three Point Polar Alignment - -#### TPPA.ELAPSED - -```json5 -{ - "id": "", - "elapsedTime": 0, - "stepCount": 0, - "state": "SLEWING|SOLVING|SOLVED|COMPUTED|FAILED|FINISHED", - "rightAscension": "00h00m00s", - "declination": "00d00m00s", - "azimuthError": "00d00m00s", - "altitudeError": "00d00m00s", - "totalError": "00d00m00s", - "azimuthErrorDirection": "", - "altitudeErrorDirection": "" -} -``` - -### Flat Wizard - -#### FLAT_WIZARD.ELAPSED - -```json5 -{ - "state": "EXPOSURING|CAPTURED|FAILED", - "exposureTime": 0, - "savedPath": "", - // CAMERA.CAPTURE_ELAPSED - "capture": {}, - "message": "" -} -``` - -### Sequencer - -#### SEQUENCER.ELAPSED - -```json5 -{ - "id": 0, - "elapsedTime": 0, - "remainingTime": 0, - "progress": 0.0, - // CAMERA.CAPTURE_ELAPSED - "capture": {} -} -``` - -### INDI - -#### DEVICE.PROPERTY_CHANGED, DEVICE.PROPERTY_DELETED - -```json5 -{ - "device": {}, - "property": {} -} -``` - -#### DEVICE.MESSAGE_RECEIVED - -```json5 -{ - "device": {}, - "message": "" -} -``` diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt index 487ee934b..f864cb21b 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentController.kt @@ -1,8 +1,9 @@ package nebulosa.api.alignment.polar +import nebulosa.api.alignment.polar.darv.DARVEvent import nebulosa.api.alignment.polar.darv.DARVStartRequest +import nebulosa.api.alignment.polar.tppa.TPPAEvent import nebulosa.api.alignment.polar.tppa.TPPAStartRequest -import nebulosa.api.beans.converters.device.DeviceOrEntityParam import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.guide.GuideOutput import nebulosa.indi.device.mount.Mount @@ -16,33 +17,43 @@ class PolarAlignmentController( @PutMapping("darv/{camera}/{guideOutput}/start") fun darvStart( - @DeviceOrEntityParam camera: Camera, @DeviceOrEntityParam guideOutput: GuideOutput, + camera: Camera, guideOutput: GuideOutput, @RequestBody body: DARVStartRequest, ) = polarAlignmentService.darvStart(camera, guideOutput, body) - @PutMapping("darv/{id}/stop") - fun darvStop(@PathVariable id: String) { - polarAlignmentService.darvStop(id) + @PutMapping("darv/{camera}/stop") + fun darvStop(camera: Camera) { + polarAlignmentService.darvStop(camera) + } + + @GetMapping("darv/{camera}/status") + fun darvStatus(camera: Camera): DARVEvent? { + return polarAlignmentService.darvStatus(camera) } @PutMapping("tppa/{camera}/{mount}/start") fun tppaStart( - @DeviceOrEntityParam camera: Camera, @DeviceOrEntityParam mount: Mount, + camera: Camera, mount: Mount, @RequestBody body: TPPAStartRequest, ) = polarAlignmentService.tppaStart(camera, mount, body) - @PutMapping("tppa/{id}/stop") - fun tppaStop(@PathVariable id: String) { - polarAlignmentService.tppaStop(id) + @PutMapping("tppa/{camera}/stop") + fun tppaStop(camera: Camera) { + polarAlignmentService.tppaStop(camera) + } + + @PutMapping("tppa/{camera}/pause") + fun tppaPause(camera: Camera) { + polarAlignmentService.tppaPause(camera) } - @PutMapping("tppa/{id}/pause") - fun tppaPause(@PathVariable id: String) { - polarAlignmentService.tppaPause(id) + @PutMapping("tppa/{camera}/unpause") + fun tppaUnpause(camera: Camera) { + polarAlignmentService.tppaUnpause(camera) } - @PutMapping("tppa/{id}/unpause") - fun tppaUnpause(@PathVariable id: String) { - polarAlignmentService.tppaUnpause(id) + @GetMapping("tppa/{camera}/status") + fun tppaStatus(camera: Camera): TPPAEvent? { + return polarAlignmentService.tppaStatus(camera) } } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt index 1c43fa76e..5a2ca14a1 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/PolarAlignmentService.kt @@ -1,7 +1,9 @@ package nebulosa.api.alignment.polar +import nebulosa.api.alignment.polar.darv.DARVEvent import nebulosa.api.alignment.polar.darv.DARVExecutor import nebulosa.api.alignment.polar.darv.DARVStartRequest +import nebulosa.api.alignment.polar.tppa.TPPAEvent import nebulosa.api.alignment.polar.tppa.TPPAExecutor import nebulosa.api.alignment.polar.tppa.TPPAStartRequest import nebulosa.indi.device.camera.Camera @@ -15,31 +17,35 @@ class PolarAlignmentService( private val tppaExecutor: TPPAExecutor, ) { - fun darvStart(camera: Camera, guideOutput: GuideOutput, darvStartRequest: DARVStartRequest): String { - check(camera.connected) { "camera not connected" } - check(guideOutput.connected) { "guide output not connected" } - return darvExecutor.execute(camera, guideOutput, darvStartRequest) + fun darvStart(camera: Camera, guideOutput: GuideOutput, darvStartRequest: DARVStartRequest) { + darvExecutor.execute(camera, guideOutput, darvStartRequest) } - fun darvStop(id: String) { - darvExecutor.stop(id) + fun darvStop(camera: Camera) { + darvExecutor.stop(camera) } - fun tppaStart(camera: Camera, mount: Mount, tppaStartRequest: TPPAStartRequest): String { - check(camera.connected) { "camera not connected" } - check(mount.connected) { "mount not connected" } - return tppaExecutor.execute(camera, mount, tppaStartRequest) + fun darvStatus(camera: Camera): DARVEvent? { + return darvExecutor.status(camera) } - fun tppaStop(id: String) { - tppaExecutor.stop(id) + fun tppaStart(camera: Camera, mount: Mount, tppaStartRequest: TPPAStartRequest) { + tppaExecutor.execute(camera, mount, tppaStartRequest) } - fun tppaPause(id: String) { - tppaExecutor.pause(id) + fun tppaStop(camera: Camera) { + tppaExecutor.stop(camera) } - fun tppaUnpause(id: String) { - tppaExecutor.unpause(id) + fun tppaPause(camera: Camera) { + tppaExecutor.pause(camera) + } + + fun tppaUnpause(camera: Camera) { + tppaExecutor.unpause(camera) + } + + fun tppaStatus(camera: Camera): TPPAEvent? { + return tppaExecutor.status(camera) } } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVEvent.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVEvent.kt index 2b768a6c1..a4c985058 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVEvent.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVEvent.kt @@ -1,59 +1,16 @@ package nebulosa.api.alignment.polar.darv +import nebulosa.api.cameras.CameraCaptureEvent import nebulosa.api.messages.MessageEvent import nebulosa.guiding.GuideDirection -import java.time.Duration +import nebulosa.indi.device.camera.Camera -sealed interface DARVEvent : MessageEvent { +data class DARVEvent( + @JvmField val camera: Camera, + @JvmField val state: DARVState = DARVState.IDLE, + @JvmField val direction: GuideDirection? = null, + @JvmField val capture: CameraCaptureEvent? = null, +) : MessageEvent { - val id: String - - val remainingTime: Duration - - val progress: Double - - val direction: GuideDirection? - - val state: DARVState - - override val eventName - get() = "DARV.ELAPSED" - - data class Started( - override val id: String, - override val remainingTime: Duration, - override val direction: GuideDirection, - ) : DARVEvent { - - override val progress = 0.0 - override val state = DARVState.INITIAL_PAUSE - } - - data class Finished( - override val id: String, - ) : DARVEvent { - - override val remainingTime = Duration.ZERO!! - override val progress = 0.0 - override val state = DARVState.IDLE - override val direction = null - } - - data class InitialPauseElapsed( - override val id: String, - override val remainingTime: Duration, - override val progress: Double, - ) : DARVEvent { - - override val state = DARVState.INITIAL_PAUSE - override val direction = null - } - - data class GuidePulseElapsed( - override val id: String, - override val remainingTime: Duration, - override val progress: Double, - override val direction: GuideDirection, - override val state: DARVState, - ) : MessageEvent, DARVEvent + override val eventName = "DARV.ELAPSED" } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt index dd6f5b7aa..1033e04c2 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVExecutor.kt @@ -1,37 +1,61 @@ package nebulosa.api.alignment.polar.darv +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.beans.annotations.Subscriber +import nebulosa.api.messages.MessageEvent import nebulosa.api.messages.MessageService -import nebulosa.batch.processing.JobExecutor -import nebulosa.batch.processing.JobLauncher import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.camera.CameraEvent import nebulosa.indi.device.guide.GuideOutput -import nebulosa.log.info -import nebulosa.log.loggerFor +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor import org.springframework.stereotype.Component +import java.util.concurrent.ConcurrentHashMap /** * @see Reference */ @Component +@Subscriber class DARVExecutor( - override val jobLauncher: JobLauncher, private val messageService: MessageService, -) : JobExecutor() { + private val threadPoolTaskExecutor: ThreadPoolTaskExecutor, +) : Consumer { + + private val jobs = ConcurrentHashMap.newKeySet(1) + + override fun accept(event: MessageEvent) { + messageService.sendMessage(event) + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + fun onCameraEvent(event: CameraEvent) { + jobs.find { it.task.camera === event.device }?.handleCameraEvent(event) + } @Synchronized - fun execute(camera: Camera, guideOutput: GuideOutput, request: DARVStartRequest): String { - check(findJobExecutionWithAny(camera, guideOutput) == null) { "DARV job is already running" } + fun execute(camera: Camera, guideOutput: GuideOutput, request: DARVStartRequest) { + check(camera.connected) { "${camera.name} Camera is not connected" } + check(guideOutput.connected) { "${guideOutput.name} Guide Output is not connected" } + check(jobs.none { it.task.camera === camera }) { "${camera.name} DARV Job is already in progress" } + check(jobs.none { it.task.guideOutput === guideOutput }) { "${camera.name} DARV Job is already in progress" } - LOG.info { "starting DARV. camera=$camera, guideOutput=$guideOutput, request=$request" } + val task = DARVTask(camera, guideOutput, request, threadPoolTaskExecutor) + task.subscribe(this) - val darvJob = DARVJob(camera, guideOutput, request) - darvJob.subscribe(messageService::sendMessage) - register(jobLauncher.launch(darvJob)) - return darvJob.id + with(DARVJob(task)) { + jobs.add(this) + whenComplete { _, _ -> jobs.remove(this) } + start() + } } - companion object { + fun stop(camera: Camera) { + jobs.find { it.task.camera === camera }?.stop() + } - @JvmStatic private val LOG = loggerFor() + fun status(camera: Camera): DARVEvent? { + return jobs.find { it.task.camera === camera }?.task?.get() as? DARVEvent } } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt index 95c69cbfc..5889a5907 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVJob.kt @@ -1,91 +1,13 @@ package nebulosa.api.alignment.polar.darv -import io.reactivex.rxjava3.subjects.PublishSubject -import nebulosa.api.cameras.AutoSubFolderMode -import nebulosa.api.cameras.CameraCaptureListener -import nebulosa.api.cameras.CameraExposureFinished -import nebulosa.api.cameras.CameraExposureStep -import nebulosa.api.guiding.GuidePulseListener -import nebulosa.api.guiding.GuidePulseRequest -import nebulosa.api.guiding.GuidePulseStep -import nebulosa.api.messages.MessageEvent -import nebulosa.batch.processing.* -import nebulosa.batch.processing.ExecutionContext.Companion.getDouble -import nebulosa.batch.processing.ExecutionContext.Companion.getDuration -import nebulosa.batch.processing.delay.DelayStep -import nebulosa.batch.processing.delay.DelayStepListener -import nebulosa.image.format.ImageRepresentation -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.camera.FrameType -import nebulosa.indi.device.guide.GuideOutput -import java.nio.file.Files -import java.nio.file.Path -import java.time.Duration +import nebulosa.api.tasks.Job +import nebulosa.indi.device.camera.CameraEvent -data class DARVJob( - @JvmField val camera: Camera, - @JvmField val guideOutput: GuideOutput, - @JvmField val request: DARVStartRequest, -) : SimpleJob(), PublishSubscribe, CameraCaptureListener, GuidePulseListener, DelayStepListener { +data class DARVJob(override val task: DARVTask) : Job() { - @JvmField val direction = if (request.reversed) request.direction.reversed else request.direction + override val name = "${task.camera.name} DARV Job" - @JvmField val cameraRequest = request.capture.copy( - exposureTime = request.capture.exposureTime + request.capture.exposureDelay, - savePath = Files.createTempDirectory("darv"), - exposureAmount = 1, exposureDelay = Duration.ZERO, - frameType = FrameType.LIGHT, autoSave = false, autoSubFolderMode = AutoSubFolderMode.OFF - ) - - override val subject = PublishSubject.create() - - init { - val cameraExposureStep = CameraExposureStep(camera, cameraRequest) - cameraExposureStep.registerCameraCaptureListener(this) - - val initialPauseDelayStep = DelayStep(request.capture.exposureDelay) - initialPauseDelayStep.registerDelayStepListener(this) - - val guidePulseDuration = request.capture.exposureTime.dividedBy(2L) - val forwardGuidePulseRequest = GuidePulseRequest(direction, guidePulseDuration) - val forwardGuidePulseStep = GuidePulseStep(guideOutput, forwardGuidePulseRequest) - forwardGuidePulseStep.registerGuidePulseListener(this) - - val backwardGuidePulseRequest = GuidePulseRequest(direction.reversed, guidePulseDuration) - val backwardGuidePulseStep = GuidePulseStep(guideOutput, backwardGuidePulseRequest) - backwardGuidePulseStep.registerGuidePulseListener(this) - - val guideFlow = SimpleFlowStep(initialPauseDelayStep, forwardGuidePulseStep, backwardGuidePulseStep) - register(SimpleSplitStep(cameraExposureStep, guideFlow)) - } - - override fun beforeJob(jobExecution: JobExecution) { - onNext(DARVEvent.Started(id, request.capture.exposureDelay, direction)) - } - - override fun afterJob(jobExecution: JobExecution) { - onNext(DARVEvent.Finished(id)) - } - - override fun onExposureFinished(step: CameraExposureStep, stepExecution: StepExecution, image: ImageRepresentation?, savedPath: Path) { - onNext(CameraExposureFinished(stepExecution.jobExecution, step.camera, 1, 1, Duration.ZERO, 1.0, Duration.ZERO, savedPath)) - } - - override fun onGuidePulseElapsed(step: GuidePulseStep, stepExecution: StepExecution) { - val direction = step.request.direction - val remainingTime = stepExecution.context.getDuration(DelayStep.REMAINING_TIME) - val progress = stepExecution.context.getDouble(DelayStep.PROGRESS) - val state = if (direction == this.direction) DARVState.FORWARD else DARVState.BACKWARD - onNext(DARVEvent.GuidePulseElapsed(id, remainingTime, progress, direction, state)) - } - - override fun onDelayElapsed(step: DelayStep, stepExecution: StepExecution) { - val remainingTime = stepExecution.context.getDuration(DelayStep.REMAINING_TIME) - val progress = stepExecution.context.getDouble(DelayStep.PROGRESS) - onNext(DARVEvent.InitialPauseElapsed(id, remainingTime, progress)) - } - - override fun contains(data: Any): Boolean { - return data === camera || data === guideOutput || super.contains(data) + fun handleCameraEvent(event: CameraEvent) { + task.handleCameraEvent(event) } } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVTask.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVTask.kt new file mode 100644 index 000000000..1a6041ab4 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVTask.kt @@ -0,0 +1,130 @@ +package nebulosa.api.alignment.polar.darv + +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.cameras.AutoSubFolderMode +import nebulosa.api.cameras.CameraCaptureEvent +import nebulosa.api.cameras.CameraCaptureState +import nebulosa.api.cameras.CameraCaptureTask +import nebulosa.api.guiding.GuidePulseEvent +import nebulosa.api.guiding.GuidePulseRequest +import nebulosa.api.guiding.GuidePulseTask +import nebulosa.api.messages.MessageEvent +import nebulosa.api.tasks.AbstractTask +import nebulosa.api.tasks.SplitTask +import nebulosa.api.tasks.Task +import nebulosa.api.tasks.delay.DelayEvent +import nebulosa.api.tasks.delay.DelayTask +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.guiding.GuideDirection +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.camera.CameraEvent +import nebulosa.indi.device.camera.FrameType +import nebulosa.indi.device.guide.GuideOutput +import nebulosa.log.loggerFor +import java.nio.file.Files +import java.time.Duration +import java.util.concurrent.Executor + +data class DARVTask( + @JvmField val camera: Camera, + @JvmField val guideOutput: GuideOutput, + @JvmField val request: DARVStartRequest, + private val executor: Executor, +) : AbstractTask(), Consumer { + + @JvmField val cameraRequest = request.capture.copy( + exposureTime = request.capture.exposureTime + request.capture.exposureDelay, + savePath = Files.createTempDirectory("darv"), + exposureAmount = 1, exposureDelay = Duration.ZERO, + frameType = FrameType.LIGHT, autoSave = false, autoSubFolderMode = AutoSubFolderMode.OFF + ) + + private val cameraCaptureTask = CameraCaptureTask(camera, cameraRequest) + private val delayTask = DelayTask(request.capture.exposureDelay) + private val forwardGuidePulseTask: GuidePulseTask + private val backwardGuidePulseTask: GuidePulseTask + + @Volatile private var state = DARVState.IDLE + @Volatile private var direction: GuideDirection? = null + + init { + val direction = if (request.reversed) request.direction.reversed else request.direction + val guidePulseDuration = request.capture.exposureTime.dividedBy(2L) + + forwardGuidePulseTask = GuidePulseTask(guideOutput, GuidePulseRequest(direction, guidePulseDuration)) + backwardGuidePulseTask = GuidePulseTask(guideOutput, GuidePulseRequest(direction.reversed, guidePulseDuration)) + + cameraCaptureTask.subscribe(this) + delayTask.subscribe(this) + forwardGuidePulseTask.subscribe(this) + backwardGuidePulseTask.subscribe(this) + } + + fun handleCameraEvent(event: CameraEvent) { + cameraCaptureTask.handleCameraEvent(event) + } + + override fun execute(cancellationToken: CancellationToken) { + LOG.info("DARV started. camera={}, guideOutput={}, request={}", camera, guideOutput, request) + + camera.snoop(listOf(guideOutput)) + + val task = SplitTask(listOf(cameraCaptureTask, Task.of(delayTask, forwardGuidePulseTask, backwardGuidePulseTask)), executor) + task.execute(cancellationToken) + + state = DARVState.IDLE + sendEvent() + + LOG.info("DARV finished. camera={}, guideOutput={}, request={}", camera, guideOutput, request) + } + + override fun canUseAsLastEvent(event: MessageEvent) = event is DARVEvent + + override fun accept(event: Any) { + when (event) { + is DelayEvent -> { + state = DARVState.INITIAL_PAUSE + } + is CameraCaptureEvent -> { + if (event.state == CameraCaptureState.EXPOSURE_FINISHED) { + onNext(event) + } + + sendEvent(event) + } + is GuidePulseEvent -> { + direction = event.task.request.direction + state = if (direction == forwardGuidePulseTask.request.direction) DARVState.FORWARD else DARVState.BACKWARD + } + else -> return LOG.warn("unknown event: {}", event) + } + } + + @Suppress("NOTHING_TO_INLINE") + private inline fun sendEvent(capture: CameraCaptureEvent? = null) { + onNext(DARVEvent(camera, state, direction, capture)) + } + + override fun reset() { + state = DARVState.IDLE + direction = null + + cameraCaptureTask.reset() + delayTask.reset() + forwardGuidePulseTask.reset() + backwardGuidePulseTask.reset() + } + + override fun close() { + cameraCaptureTask.close() + delayTask.close() + forwardGuidePulseTask.close() + backwardGuidePulseTask.close() + super.close() + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt index c16deed1b..c584fb2cf 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAEvent.kt @@ -3,114 +3,23 @@ package nebulosa.api.alignment.polar.tppa import com.fasterxml.jackson.databind.annotation.JsonSerialize import nebulosa.api.beans.converters.angle.DeclinationSerializer import nebulosa.api.beans.converters.angle.RightAscensionSerializer +import nebulosa.api.cameras.CameraCaptureEvent import nebulosa.api.messages.MessageEvent +import nebulosa.indi.device.camera.Camera import nebulosa.math.Angle -import java.time.Duration -import kotlin.math.hypot -sealed interface TPPAEvent : MessageEvent { - - val id: String - - val state: TPPAState - - val stepCount: Int - - val elapsedTime: Duration - - val rightAscension: Angle - get() = 0.0 - - val declination: Angle - get() = 0.0 - - val azimuthError: Angle - get() = 0.0 - - val altitudeError: Angle - get() = 0.0 - - val totalError: Angle - get() = 0.0 - - val azimuthErrorDirection: String - get() = "" - - val altitudeErrorDirection: String - get() = "" - - override val eventName - get() = "TPPA.ELAPSED" - - data class Slewing( - override val id: String, - override val stepCount: Int, - override val elapsedTime: Duration, - @field:JsonSerialize(using = RightAscensionSerializer::class) override val rightAscension: Angle, - @field:JsonSerialize(using = DeclinationSerializer::class) override val declination: Angle, - ) : TPPAEvent { - - override val state = TPPAState.SLEWING - } - - data class Solving( - override val id: String, - override val stepCount: Int, - override val elapsedTime: Duration, - ) : TPPAEvent { - - override val state = TPPAState.SOLVING - } - - data class Solved( - override val id: String, - override val stepCount: Int, - override val elapsedTime: Duration, - @field:JsonSerialize(using = RightAscensionSerializer::class) override val rightAscension: Angle, - @field:JsonSerialize(using = DeclinationSerializer::class) override val declination: Angle, - ) : TPPAEvent { - - override val state = TPPAState.SOLVED - } - - data class Paused( - override val id: String, - override val stepCount: Int, - override val elapsedTime: Duration, - ) : TPPAEvent { - - override val state = TPPAState.PAUSED - } - - data class Computed( - override val id: String, - override val stepCount: Int, - override val elapsedTime: Duration, - @field:JsonSerialize(using = DeclinationSerializer::class) override val azimuthError: Angle, - @field:JsonSerialize(using = DeclinationSerializer::class) override val altitudeError: Angle, - override val azimuthErrorDirection: String, - override val altitudeErrorDirection: String, - ) : TPPAEvent { - - @JsonSerialize(using = DeclinationSerializer::class) override val totalError = hypot(azimuthError, altitudeError) - override val state = TPPAState.COMPUTED - } - - data class Failed( - override val id: String, - override val stepCount: Int, - override val elapsedTime: Duration, - ) : TPPAEvent { - - override val state = TPPAState.FAILED - } - - data class Finished( - override val id: String, - ) : TPPAEvent { - - override val stepCount = 0 - override val elapsedTime: Duration = Duration.ZERO - override val state = TPPAState.FINISHED - } +data class TPPAEvent( + @JvmField val camera: Camera, + @JvmField val state: TPPAState = TPPAState.IDLE, + @JvmField @field:JsonSerialize(using = RightAscensionSerializer::class) val rightAscension: Angle = 0.0, + @JvmField @field:JsonSerialize(using = DeclinationSerializer::class) val declination: Angle = 0.0, + @JvmField @field:JsonSerialize(using = DeclinationSerializer::class) val azimuthError: Angle = 0.0, + @JvmField @field:JsonSerialize(using = DeclinationSerializer::class) val altitudeError: Angle = 0.0, + @JvmField @JsonSerialize(using = DeclinationSerializer::class) val totalError: Angle = 0.0, + @JvmField val azimuthErrorDirection: String = "", + @JvmField val altitudeErrorDirection: String = "", + @JvmField val capture: CameraCaptureEvent? = null, +) : MessageEvent { + + override val eventName = "TPPA.ELAPSED" } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt index 5c941ebf5..d4b900519 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAExecutor.kt @@ -1,47 +1,67 @@ package nebulosa.api.alignment.polar.tppa import io.reactivex.rxjava3.functions.Consumer -import nebulosa.api.cameras.CameraExposureFinished +import nebulosa.api.beans.annotations.Subscriber import nebulosa.api.messages.MessageEvent import nebulosa.api.messages.MessageService import nebulosa.api.solver.PlateSolverService -import nebulosa.batch.processing.JobExecutor -import nebulosa.batch.processing.JobLauncher import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.camera.CameraEvent import nebulosa.indi.device.mount.Mount -import nebulosa.log.info -import nebulosa.log.loggerFor +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode import org.springframework.stereotype.Component +import java.util.concurrent.ConcurrentHashMap @Component +@Subscriber class TPPAExecutor( - override val jobLauncher: JobLauncher, private val messageService: MessageService, private val plateSolverService: PlateSolverService, -) : JobExecutor(), Consumer { +) : Consumer { - @Synchronized - fun execute(camera: Camera, mount: Mount, request: TPPAStartRequest): String { - check(findJobExecutionWithAny(camera, mount) == null) { "TPPA job is already running" } + private val jobs = ConcurrentHashMap.newKeySet(1) + + override fun accept(event: MessageEvent) { + messageService.sendMessage(event) + } - LOG.info { "starting TPPA. camera=$camera, mount=$mount, request=$request" } + @Subscribe(threadMode = ThreadMode.ASYNC) + fun onCameraEvent(event: CameraEvent) { + jobs.find { it.task.camera === event.device }?.handleCameraEvent(event) + } + + @Synchronized + fun execute(camera: Camera, mount: Mount, request: TPPAStartRequest) { + check(camera.connected) { "${camera.name} Camera is not connected" } + check(mount.connected) { "${mount.name} Mount is not connected" } + check(jobs.none { it.task.camera === camera }) { "${camera.name} TPPA Job is already in progress" } + check(jobs.none { it.task.mount === mount }) { "${camera.name} TPPA Job is already in progress" } val solver = plateSolverService.solverFor(request.plateSolver) + val task = TPPATask(camera, solver, request, mount) + task.subscribe(this) - val tppaJob = TPPAJob(camera, request, solver, mount) - tppaJob.subscribe(this) - register(jobLauncher.launch(tppaJob)) - return tppaJob.id + with(TPPAJob(task)) { + jobs.add(this) + whenComplete { _, _ -> jobs.remove(this) } + start() + } } - override fun accept(event: MessageEvent) { - if (event is TPPAEvent || event is CameraExposureFinished) { - messageService.sendMessage(event) - } + fun stop(camera: Camera) { + jobs.find { it.task.camera === camera }?.stop() } - companion object { + fun pause(camera: Camera) { + jobs.find { it.task.camera === camera }?.pause() + } + + fun unpause(camera: Camera) { + jobs.find { it.task.camera === camera }?.unpause() + } - @JvmStatic private val LOG = loggerFor() + fun status(camera: Camera): TPPAEvent? { + return jobs.find { it.task.camera === camera }?.task?.get() as? TPPAEvent } } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt index 821a281c9..a11a45abe 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAJob.kt @@ -1,94 +1,13 @@ package nebulosa.api.alignment.polar.tppa -import io.reactivex.rxjava3.subjects.PublishSubject -import nebulosa.api.cameras.AutoSubFolderMode -import nebulosa.api.cameras.CameraCaptureEventHandler -import nebulosa.api.cameras.CameraCaptureListener -import nebulosa.api.messages.MessageEvent -import nebulosa.batch.processing.PublishSubscribe -import nebulosa.batch.processing.SimpleJob -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.camera.FrameType -import nebulosa.indi.device.mount.Mount -import nebulosa.math.Angle -import nebulosa.plate.solving.PlateSolver -import java.nio.file.Files -import java.time.Duration +import nebulosa.api.tasks.Job +import nebulosa.indi.device.camera.CameraEvent -data class TPPAJob( - @JvmField val camera: Camera, - @JvmField val request: TPPAStartRequest, - @JvmField val solver: PlateSolver, - @JvmField val mount: Mount? = null, - @JvmField val longitude: Angle = mount!!.longitude, - @JvmField val latitude: Angle = mount!!.latitude, -) : SimpleJob(), PublishSubscribe, CameraCaptureListener, TPPAListener { +data class TPPAJob(override val task: TPPATask) : Job() { - @JvmField val cameraRequest = request.capture.copy( - savePath = Files.createTempDirectory("tppa"), - exposureAmount = 1, exposureDelay = Duration.ZERO, - exposureTime = maxOf(request.capture.exposureTime, MIN_EXPOSURE_TIME), - frameType = FrameType.LIGHT, autoSave = false, autoSubFolderMode = AutoSubFolderMode.OFF - ) + override val name = "${task.camera.name} TPPA Job" - override val subject = PublishSubject.create() - - private val cameraCaptureEventHandler = CameraCaptureEventHandler(this) - private val tppaStep = TPPAStep(camera, solver, request, mount, longitude, latitude, cameraRequest) - - init { - tppaStep.registerCameraCaptureListener(cameraCaptureEventHandler) - tppaStep.registerTPPAListener(this) - - register(tppaStep) - } - - override fun slewStarted(step: TPPAStep, rightAscension: Angle, declination: Angle) { - onNext(TPPAEvent.Slewing(id, step.stepCount, step.elapsedTime, rightAscension, declination)) - } - - override fun solverStarted(step: TPPAStep) { - onNext(TPPAEvent.Solving(id, step.stepCount, step.elapsedTime)) - } - - override fun solverFinished(step: TPPAStep, rightAscension: Angle, declination: Angle) { - onNext(TPPAEvent.Solved(id, step.stepCount, step.elapsedTime, rightAscension, declination)) - } - - override fun polarAlignmentPaused(step: TPPAStep) { - onNext(TPPAEvent.Paused(id, step.stepCount, step.elapsedTime)) - } - - override fun polarAlignmentComputed(step: TPPAStep, azimuth: Angle, altitude: Angle) { - val azimuthErrorDirection = when { - azimuth > 0 -> if (latitude > 0) "🠔 Move LEFT/WEST" else "🠔 Move LEFT/EAST" - azimuth < 0 -> if (latitude > 0) "Move RIGHT/EAST 🠖" else "Move RIGHT/WEST 🠖" - else -> "" - } - - val altitudeErrorDirection = when { - altitude > 0 -> if (latitude > 0) "🠗 Move DOWN" else "Move UP 🠕" - altitude < 0 -> if (latitude > 0) "Move UP 🠕" else "🠗 Move DOWN" - else -> "" - } - - onNext(TPPAEvent.Computed(id, step.stepCount, step.elapsedTime, azimuth, altitude, azimuthErrorDirection, altitudeErrorDirection)) - } - - override fun solverFailed(step: TPPAStep) { - onNext(TPPAEvent.Failed(id, step.stepCount, step.elapsedTime)) - } - - override fun polarAlignmentFinished(step: TPPAStep, aborted: Boolean) { - onNext(TPPAEvent.Finished(id)) - } - - override fun contains(data: Any): Boolean { - return data === camera || data === mount || super.contains(data) - } - - companion object { - - @JvmStatic private val MIN_EXPOSURE_TIME: Duration = Duration.ofSeconds(1L) + fun handleCameraEvent(event: CameraEvent) { + task.handleCameraEvent(event) } } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAListener.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAListener.kt deleted file mode 100644 index a14e666c2..000000000 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAListener.kt +++ /dev/null @@ -1,20 +0,0 @@ -package nebulosa.api.alignment.polar.tppa - -import nebulosa.math.Angle - -interface TPPAListener { - - fun slewStarted(step: TPPAStep, rightAscension: Angle, declination: Angle) - - fun solverStarted(step: TPPAStep) - - fun solverFinished(step: TPPAStep, rightAscension: Angle, declination: Angle) - - fun polarAlignmentPaused(step: TPPAStep) - - fun polarAlignmentComputed(step: TPPAStep, azimuth: Angle, altitude: Angle) - - fun solverFailed(step: TPPAStep) - - fun polarAlignmentFinished(step: TPPAStep, aborted: Boolean) -} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStartRequest.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStartRequest.kt index 26b4d0efd..acb655885 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 @@ -5,13 +5,19 @@ import jakarta.validation.Valid import jakarta.validation.constraints.NotNull import nebulosa.api.cameras.CameraStartCaptureRequest import nebulosa.api.solver.PlateSolverOptions +import nebulosa.guiding.GuideDirection +import org.hibernate.validator.constraints.time.DurationMin +import org.springframework.boot.convert.DurationUnit +import java.time.Duration +import java.time.temporal.ChronoUnit data class TPPAStartRequest( - @JsonIgnoreProperties("camera", "focuser", "wheel") val capture: CameraStartCaptureRequest = CameraStartCaptureRequest.EMPTY, - @field:NotNull @Valid val plateSolver: PlateSolverOptions = PlateSolverOptions.EMPTY, - val startFromCurrentPosition: Boolean = true, - val eastDirection: Boolean = true, - val compensateRefraction: Boolean = false, - val stopTrackingWhenDone: Boolean = true, - val stepDistance: Double = 10.0, // degrees + @JsonIgnoreProperties("camera", "focuser", "wheel") @JvmField val capture: CameraStartCaptureRequest = CameraStartCaptureRequest.EMPTY, + @field:NotNull @Valid @JvmField val plateSolver: PlateSolverOptions = PlateSolverOptions.EMPTY, + @JvmField val startFromCurrentPosition: Boolean = true, + @JvmField val compensateRefraction: Boolean = false, + @JvmField val stopTrackingWhenDone: Boolean = true, + @field:DurationMin(seconds = 1L) @JvmField val stepDirection: GuideDirection = GuideDirection.EAST, + @field:DurationUnit(ChronoUnit.SECONDS) @field:DurationMin(seconds = 1L) @JvmField val stepDuration: Duration = Duration.ZERO, + @JvmField val stepSpeed: String? = null, ) diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAState.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAState.kt index 62f507b39..0e1a5c0ef 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAState.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAState.kt @@ -1,11 +1,16 @@ package nebulosa.api.alignment.polar.tppa enum class TPPAState { + IDLE, SLEWING, + SLEWED, + SETTLING, + EXPOSURING, SOLVING, SOLVED, - PAUSED, COMPUTED, - FAILED, + PAUSING, + PAUSED, FINISHED, + FAILED, } diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt deleted file mode 100644 index af809ffa5..000000000 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPAStep.kt +++ /dev/null @@ -1,193 +0,0 @@ -package nebulosa.api.alignment.polar.tppa - -import nebulosa.alignment.polar.point.three.ThreePointPolarAlignment -import nebulosa.alignment.polar.point.three.ThreePointPolarAlignmentResult -import nebulosa.api.cameras.CameraCaptureListener -import nebulosa.api.cameras.CameraExposureStep -import nebulosa.api.cameras.CameraStartCaptureRequest -import nebulosa.api.mounts.MountSlewStep -import nebulosa.batch.processing.JobExecution -import nebulosa.batch.processing.Step -import nebulosa.batch.processing.StepExecution -import nebulosa.batch.processing.StepResult -import nebulosa.common.concurrency.latch.Pauseable -import nebulosa.common.time.Stopwatch -import nebulosa.image.format.ImageRepresentation -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.mount.Mount -import nebulosa.log.debug -import nebulosa.log.loggerFor -import nebulosa.math.Angle -import nebulosa.math.deg -import nebulosa.plate.solving.PlateSolver -import java.nio.file.Path - -data class TPPAStep( - @JvmField val camera: Camera, - private val solver: PlateSolver, - private val request: TPPAStartRequest, - @JvmField val mount: Mount? = null, - private val longitude: Angle = mount!!.longitude, - private val latitude: Angle = mount!!.latitude, - private val cameraRequest: CameraStartCaptureRequest = request.capture, -) : Step, Pauseable, CameraCaptureListener { - - private val cameraExposureStep = CameraExposureStep(camera, cameraRequest) - private val alignment = ThreePointPolarAlignment(solver, longitude, latitude) - private val listeners = LinkedHashSet() - private val stopwatch = Stopwatch() - private val stepDistances = DoubleArray(2) { if (request.eastDirection) request.stepDistance else -request.stepDistance } - - @Volatile private var mountSlewStep: MountSlewStep? = null - @Volatile private var noSolutionAttempts = 0 - @Volatile private var stepExecution: StepExecution? = null - @Volatile private var savedImage: Pair? = null - - val stepCount - get() = alignment.state - - val elapsedTime - get() = stopwatch.elapsed - - fun registerCameraCaptureListener(listener: CameraCaptureListener): Boolean { - return cameraExposureStep.registerCameraCaptureListener(listener) - } - - fun unregisterCameraCaptureListener(listener: CameraCaptureListener): Boolean { - return cameraExposureStep.unregisterCameraCaptureListener(listener) - } - - fun registerTPPAListener(listener: TPPAListener): Boolean { - return listeners.add(listener) - } - - fun unregisterTPPAListener(listener: TPPAListener): Boolean { - return listeners.remove(listener) - } - - override fun onExposureFinished(step: CameraExposureStep, stepExecution: StepExecution, image: ImageRepresentation?, savedPath: Path) { - savedImage = image to savedPath - } - - override fun beforeJob(jobExecution: JobExecution) { - cameraExposureStep.registerCameraCaptureListener(this) - cameraExposureStep.beforeJob(jobExecution) - mount?.tracking(true) - } - - override fun afterJob(jobExecution: JobExecution) { - cameraExposureStep.afterJob(jobExecution) - cameraExposureStep.unregisterCameraCaptureListener(this) - - if (mount != null && request.stopTrackingWhenDone) { - mount.tracking(false) - } - - savedImage = null - stopwatch.stop() - listeners.forEach { it.polarAlignmentFinished(this, jobExecution.cancellationToken.isCancelled) } - } - - override fun execute(stepExecution: StepExecution): StepResult { - val cancellationToken = stepExecution.jobExecution.cancellationToken - - if (cancellationToken.isCancelled) return StepResult.FINISHED - - LOG.debug { "executing TPPA. camera=$camera, mount=$mount, state=${alignment.state}" } - - this.stepExecution = stepExecution - - if (cancellationToken.isPaused) { - listeners.forEach { it.polarAlignmentPaused(this) } - cancellationToken.waitIfPaused() - } - - if (cancellationToken.isCancelled) return StepResult.FINISHED - - stopwatch.start() - - // Mount slew step. - if (mount != null) { - if (alignment.state in 1..2 && stepDistances[alignment.state - 1] != 0.0) { - val step = MountSlewStep(mount, mount.rightAscension + stepDistances[alignment.state - 1].deg, mount.declination) - mountSlewStep = step - listeners.forEach { it.slewStarted(this, step.rightAscension, step.declination) } - step.executeSingle(stepExecution) - stepDistances[alignment.state - 1] = 0.0 - } - } - - if (cancellationToken.isCancelled) return StepResult.FINISHED - - listeners.forEach { it.solverStarted(this) } - - // Camera capture step. - cameraExposureStep.execute(stepExecution) - - if (!cancellationToken.isCancelled) { - val saved = savedImage ?: return StepResult.FINISHED - - val radius = if (mount == null) 0.0 else ThreePointPolarAlignment.DEFAULT_RADIUS - - // Polar alignment step. - val result = alignment.align( - saved.second, mount?.rightAscension ?: 0.0, mount?.declination ?: 0.0, radius, - request.compensateRefraction, cancellationToken - ) - - LOG.info("alignment completed. result=$result, cancelled={}", cancellationToken.isCancelled) - - if (cancellationToken.isCancelled) return StepResult.FINISHED - - when (result) { - is ThreePointPolarAlignmentResult.NeedMoreMeasurement -> { - noSolutionAttempts = 0 - listeners.forEach { it.solverFinished(this, result.rightAscension, result.declination) } - return StepResult.CONTINUABLE - } - is ThreePointPolarAlignmentResult.NoPlateSolution -> { - noSolutionAttempts++ - - return if (noSolutionAttempts < 10) { - listeners.forEach { it.solverFailed(this) } - StepResult.CONTINUABLE - } else { - StepResult.FINISHED - } - } - is ThreePointPolarAlignmentResult.Measured -> { - noSolutionAttempts = 0 - - listeners.forEach { - it.solverFinished(this, result.rightAscension, result.declination) - it.polarAlignmentComputed(this, result.azimuth, result.altitude) - } - - return StepResult.CONTINUABLE - } - } - } - - return StepResult.FINISHED - } - - override fun stop(mayInterruptIfRunning: Boolean) { - mountSlewStep?.stop(mayInterruptIfRunning) - cameraExposureStep.stop(mayInterruptIfRunning) - } - - override val isPaused - get() = stepExecution?.jobExecution?.cancellationToken?.isPaused ?: false - - override fun pause() { - stopwatch.stop() - } - - override fun unpause() { - } - - companion object { - - @JvmStatic private val LOG = loggerFor() - } -} diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt new file mode 100644 index 000000000..3664401cc --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/tppa/TPPATask.kt @@ -0,0 +1,321 @@ +package nebulosa.api.alignment.polar.tppa + +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.alignment.polar.point.three.ThreePointPolarAlignment +import nebulosa.alignment.polar.point.three.ThreePointPolarAlignmentResult +import nebulosa.api.cameras.AutoSubFolderMode +import nebulosa.api.cameras.CameraCaptureEvent +import nebulosa.api.cameras.CameraCaptureState +import nebulosa.api.cameras.CameraCaptureTask +import nebulosa.api.messages.MessageEvent +import nebulosa.api.mounts.MountMoveRequest +import nebulosa.api.mounts.MountMoveTask +import nebulosa.api.tasks.AbstractTask +import nebulosa.api.tasks.delay.DelayEvent +import nebulosa.api.tasks.delay.DelayTask +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.common.concurrency.latch.PauseListener +import nebulosa.common.time.Stopwatch +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.camera.CameraEvent +import nebulosa.indi.device.camera.FrameType +import nebulosa.indi.device.mount.Mount +import nebulosa.log.loggerFor +import nebulosa.math.Angle +import nebulosa.math.formatHMS +import nebulosa.math.formatSignedDMS +import nebulosa.plate.solving.PlateSolver +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.hypot + +data class TPPATask( + @JvmField val camera: Camera, + @JvmField val solver: PlateSolver, + @JvmField val request: TPPAStartRequest, + @JvmField val mount: Mount? = null, + @JvmField val longitude: Angle = mount!!.longitude, + @JvmField val latitude: Angle = mount!!.latitude, +) : AbstractTask(), Consumer, PauseListener { + + @JvmField val mountMoveRequest = MountMoveRequest(request.stepDirection, request.stepDuration, request.stepSpeed) + + @JvmField val cameraRequest = request.capture.copy( + savePath = Files.createTempDirectory("tppa"), + exposureAmount = 0, exposureDelay = Duration.ZERO, + exposureTime = maxOf(request.capture.exposureTime, MIN_EXPOSURE_TIME), + frameType = FrameType.LIGHT, autoSave = false, autoSubFolderMode = AutoSubFolderMode.OFF + ) + + private val alignment = ThreePointPolarAlignment(solver, longitude, latitude) + private val cameraCaptureTask = CameraCaptureTask(camera, cameraRequest, exposureMaxRepeat = 1) + private val settleDelayTask = DelayTask(SETTLE_TIME) + private val mountMoveState = BooleanArray(3) + private val elapsedTime = Stopwatch() + private val pausing = AtomicBoolean() + private val finished = AtomicBoolean() + + @Volatile private var rightAscension: Angle = 0.0 + @Volatile private var declination: Angle = 0.0 + @Volatile private var azimuthError: Angle = 0.0 + @Volatile private var altitudeError: Angle = 0.0 + @Volatile private var totalError: Angle = 0.0 + @Volatile private var azimuthErrorDirection = "" + @Volatile private var altitudeErrorDirection = "" + @Volatile private var savedImage: Path? = null + @Volatile private var noSolutionAttempts = 0 + @Volatile private var captureEvent: CameraCaptureEvent? = null + + init { + cameraCaptureTask.subscribe(this) + settleDelayTask.subscribe(this) + } + + fun handleCameraEvent(event: CameraEvent) { + if (camera === event.device) { + cameraCaptureTask.handleCameraEvent(event) + } + } + + override fun canUseAsLastEvent(event: MessageEvent) = event is TPPAEvent + + override fun accept(event: Any) { + when (event) { + is CameraCaptureEvent -> { + captureEvent = event + + if (event.state == CameraCaptureState.EXPOSURE_FINISHED) { + savedImage = event.savePath!! + } + + if (!finished.get()) { + sendEvent(TPPAState.EXPOSURING, event) + } + } + is DelayEvent -> { + sendEvent(TPPAState.SETTLING) + } + } + } + + override fun execute(cancellationToken: CancellationToken) { + LOG.info( + "TPPA started. longitude={}, latitude={}, rightAscension={}, declination={}, camera={}, mount={}, request={}", + longitude.formatSignedDMS(), latitude.formatSignedDMS(), mount?.rightAscension?.formatHMS(), mount?.declination?.formatSignedDMS(), + camera, mount, request + ) + + finished.set(false) + elapsedTime.start() + + rightAscension = mount?.rightAscension ?: 0.0 + declination = mount?.declination ?: 0.0 + + camera.snoop(listOf(mount)) + + cancellationToken.listenToPause(this) + + while (!cancellationToken.isDone) { + if (cancellationToken.isPaused) { + pausing.set(false) + sendEvent(TPPAState.PAUSED) + cancellationToken.waitForPause() + } + + if (cancellationToken.isDone) break + + mount?.tracking(true) + + // SLEWING. + if (mount != null) { + if (alignment.state.ordinal in 1..2 && !mountMoveState[alignment.state.ordinal]) { + MountMoveTask(mount, mountMoveRequest).use { + sendEvent(TPPAState.SLEWING) + it.execute(cancellationToken) + mountMoveState[alignment.state.ordinal] = true + } + + if (cancellationToken.isDone) break + + rightAscension = mount.rightAscension + declination = mount.declination + sendEvent(TPPAState.SLEWED) + + LOG.info("TPPA slewed. rightAscension={}, declination={}", mount.rightAscension.formatHMS(), mount.declination.formatSignedDMS()) + + settleDelayTask.execute(cancellationToken) + } + } + + if (cancellationToken.isDone) break + + sendEvent(TPPAState.EXPOSURING) + + // CAPTURE. + cameraCaptureTask.execute(cancellationToken) + + if (cancellationToken.isDone || savedImage == null) { + break + } + + sendEvent(TPPAState.SOLVING) + + // ALIGNMENT. + val radius = if (mount == null) 0.0 else ATTEMPT_RADIUS * (noSolutionAttempts + 1) + + val result = try { + alignment.align( + savedImage!!, mount?.rightAscension ?: 0.0, mount?.declination ?: 0.0, radius, + request.compensateRefraction, cancellationToken + ) + } catch (e: Throwable) { + sendEvent(TPPAState.FAILED) + LOG.error("failed to align", e) + break + } + + savedImage = null + + LOG.info("TPPA alignment completed. result=$result") + + if (cancellationToken.isDone) break + + when (result) { + is ThreePointPolarAlignmentResult.NeedMoreMeasurement -> { + noSolutionAttempts = 0 + rightAscension = result.rightAscension + declination = result.declination + sendEvent(TPPAState.SOLVED) + continue + } + is ThreePointPolarAlignmentResult.NoPlateSolution -> { + noSolutionAttempts++ + + sendEvent(TPPAState.FAILED) + + if (noSolutionAttempts < MAX_ATTEMPTS) { + continue + } else { + LOG.error("exhausted all attempts to plate solve") + break + } + } + is ThreePointPolarAlignmentResult.Measured -> { + noSolutionAttempts = 0 + + rightAscension = result.rightAscension + declination = result.declination + azimuthError = result.azimuth + altitudeError = result.altitude + totalError = hypot(azimuthError, altitudeError) + + azimuthErrorDirection = when { + azimuthError > 0 -> if (latitude > 0) "🠔 Move LEFT/WEST" else "🠔 Move LEFT/EAST" + azimuthError < 0 -> if (latitude > 0) "Move RIGHT/EAST 🠖" else "Move RIGHT/WEST 🠖" + else -> "" + } + + altitudeErrorDirection = when { + altitudeError > 0 -> if (latitude > 0) "🠗 Move DOWN" else "Move UP 🠕" + altitudeError < 0 -> if (latitude > 0) "Move UP 🠕" else "🠗 Move DOWN" + else -> "" + } + + LOG.info( + "TPPA alignment computed. rightAscension={}, declination={}, azimuthError={}, altitudeError={}", + result.rightAscension.formatHMS(), result.declination.formatSignedDMS(), + azimuthError.formatSignedDMS(), altitudeError.formatSignedDMS(), + ) + + sendEvent(TPPAState.COMPUTED) + + continue + } + is ThreePointPolarAlignmentResult.Cancelled -> { + break + } + } + } + + pausing.set(false) + cancellationToken.unlistenToPause(this) + + finished.set(true) + elapsedTime.stop() + + if (request.stopTrackingWhenDone) { + mount?.tracking(false) + } + + sendEvent(TPPAState.FINISHED) + + LOG.info("TPPA finished. camera={}, mount={}, request={}", camera, mount, request) + } + + @Suppress("NOTHING_TO_INLINE") + private inline fun processCameraCaptureEvent(event: CameraCaptureEvent?): CameraCaptureEvent? { + return event?.copy(captureElapsedTime = elapsedTime.elapsed) + } + + private fun sendEvent(state: TPPAState, capture: CameraCaptureEvent? = captureEvent) { + val event = TPPAEvent( + camera, if (pausing.get()) TPPAState.PAUSING else state, rightAscension, declination, + azimuthError, altitudeError, totalError, + azimuthErrorDirection, altitudeErrorDirection, + processCameraCaptureEvent(capture), + ) + + onNext(event) + + if (capture?.state == CameraCaptureState.EXPOSURE_FINISHED) { + onNext(capture) + } + } + + override fun reset() { + mountMoveState.fill(false) + azimuthError = 0.0 + altitudeError = 0.0 + totalError = 0.0 + azimuthErrorDirection = "" + altitudeErrorDirection = "" + savedImage = null + noSolutionAttempts = 0 + + pausing.set(false) + finished.set(false) + elapsedTime.reset() + + cameraCaptureTask.reset() + settleDelayTask.reset() + + alignment.reset() + + super.reset() + } + + override fun onPause(paused: Boolean) { + pausing.set(paused) + + if (paused) { + sendEvent(TPPAState.PAUSING) + } + } + + override fun close() { + cameraCaptureTask.close() + super.close() + } + + companion object { + + @JvmStatic private val MIN_EXPOSURE_TIME = Duration.ofSeconds(1L) + @JvmStatic private val SETTLE_TIME = Duration.ofSeconds(5) + @JvmStatic private val LOG = loggerFor() + + const val MAX_ATTEMPTS = 30 + const val ATTEMPT_RADIUS = ThreePointPolarAlignment.DEFAULT_RADIUS / 2.0 + } +} diff --git a/api/src/main/kotlin/nebulosa/api/atlas/SkyAtlasController.kt b/api/src/main/kotlin/nebulosa/api/atlas/SkyAtlasController.kt index 5251bfee0..eac7cb08e 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/SkyAtlasController.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/SkyAtlasController.kt @@ -5,7 +5,6 @@ import jakarta.validation.Valid import jakarta.validation.constraints.Min import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Positive -import nebulosa.api.beans.converters.device.DeviceOrEntityParam import nebulosa.api.beans.converters.location.LocationParam import nebulosa.api.beans.converters.time.DateAndTimeParam import nebulosa.math.deg @@ -115,14 +114,14 @@ class SkyAtlasController( @GetMapping("satellites/{satellite}/position") fun positionOfSatellite( - @DeviceOrEntityParam satellite: SatelliteEntity, + satellite: SatelliteEntity, @LocationParam location: Location, @DateAndTimeParam dateTime: LocalDateTime, ) = skyAtlasService.positionOfSatellite(location, satellite, dateTime) @GetMapping("satellites/{satellite}/altitude-points") fun altitudePointsOfSatellite( - @DeviceOrEntityParam satellite: SatelliteEntity, + satellite: SatelliteEntity, @LocationParam location: Location, @DateAndTimeParam dateTime: LocalDate, @RequestParam(required = false, defaultValue = "1") stepSize: Int, diff --git a/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt b/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt index ff01cda52..fe3a7d905 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/configurations/BeanConfiguration.kt @@ -11,7 +11,7 @@ import nebulosa.api.atlas.SimbadEntity import nebulosa.api.calibration.CalibrationFrameEntity import nebulosa.api.database.MyObjectBox import nebulosa.api.preferences.PreferenceEntity -import nebulosa.batch.processing.AsyncJobLauncher +import nebulosa.common.concurrency.DaemonThreadFactory import nebulosa.common.json.PathDeserializer import nebulosa.common.json.PathSerializer import nebulosa.guiding.Guider @@ -42,6 +42,8 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.servlet.config.annotation.CorsRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import java.nio.file.Path +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import kotlin.io.path.createDirectories @@ -93,7 +95,7 @@ class BeanConfiguration { fun cache(cachePath: Path) = Cache(cachePath.toFile(), MAX_CACHE_SIZE) @Bean - fun httpLogger() = HttpLoggingInterceptor.Logger { OKHTTP_LOGGER.info(it) } + fun httpLogger() = HttpLoggingInterceptor.Logger { OKHTTP_LOG.info(it) } @Bean fun httpClient(connectionPool: ConnectionPool, cache: Cache, httpLogger: HttpLoggingInterceptor.Logger) = OkHttpClient.Builder() @@ -128,21 +130,27 @@ class BeanConfiguration { fun hips2FitsService(httpClient: OkHttpClient) = Hips2FitsService(httpClient = httpClient) @Bean + @Primary fun threadPoolTaskExecutor(): ThreadPoolTaskExecutor { val taskExecutor = ThreadPoolTaskExecutor() taskExecutor.corePoolSize = 32 + taskExecutor.keepAliveSeconds = 30 + taskExecutor.isDaemon = true taskExecutor.initialize() return taskExecutor } @Bean - fun eventBus(threadPoolTaskExecutor: ThreadPoolTaskExecutor) = EventBus.builder() + fun eventBusExecutorService(): ExecutorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors(), DaemonThreadFactory) + + @Bean + fun eventBus(eventBusExecutorService: ExecutorService) = EventBus.builder() .sendNoSubscriberEvent(false) .sendSubscriberExceptionEvent(false) .throwSubscriberException(false) .logNoSubscriberMessages(false) .logSubscriberExceptions(false) - .executorService(threadPoolTaskExecutor.threadPoolExecutor) + .executorService(eventBusExecutorService) .installDefaultEventBus()!! @Bean @@ -151,9 +159,6 @@ class BeanConfiguration { @Bean fun phd2Guider(phd2Client: PHD2Client): Guider = PHD2Guider(phd2Client) - @Bean - fun asyncJobLauncher(threadPoolTaskExecutor: ThreadPoolTaskExecutor) = AsyncJobLauncher(threadPoolTaskExecutor) - @Bean @Primary fun watneyStarDetector(): StarDetector = WatneyStarDetector(computeHFD = true) @@ -216,8 +221,9 @@ class BeanConfiguration { companion object { - const val MAX_CACHE_SIZE = 1024L * 1024L * 32L // 32MB + private const val MAX_CACHE_SIZE = 1024L * 1024L * 32L // 32MB - @JvmStatic private val OKHTTP_LOGGER = loggerFor() + @JvmStatic private val LOG = loggerFor() + @JvmStatic private val OKHTTP_LOG = loggerFor() } } diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/angle/AngleParamMethodArgumentResolver.kt b/api/src/main/kotlin/nebulosa/api/beans/converters/angle/AngleParamMethodArgumentResolver.kt index c2cab03e5..931d11eed 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/angle/AngleParamMethodArgumentResolver.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/converters/angle/AngleParamMethodArgumentResolver.kt @@ -23,7 +23,7 @@ class AngleParamMethodArgumentResolver : HandlerMethodArgumentResolver { mavContainer: ModelAndViewContainer?, webRequest: NativeWebRequest, binderFactory: WebDataBinderFactory?, - ): Any? { + ): Angle { val param = parameter.annotation()!! val parameterName = param.name.ifBlank { null } ?: parameter.parameterName!! val parameterValue = webRequest.parameter(parameterName) ?: param.defaultValue diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/database/PathPropertyConverter.kt b/api/src/main/kotlin/nebulosa/api/beans/converters/database/PathPropertyConverter.kt new file mode 100644 index 000000000..b3d27b1c6 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/beans/converters/database/PathPropertyConverter.kt @@ -0,0 +1,15 @@ +package nebulosa.api.beans.converters.database + +import io.objectbox.converter.PropertyConverter +import java.nio.file.Path + +class PathPropertyConverter : PropertyConverter { + + override fun convertToEntityProperty(databaseValue: String?): Path? { + return databaseValue?.let(Path::of) + } + + override fun convertToDatabaseValue(entityProperty: Path?): String? { + return entityProperty?.toString() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/device/DeviceOrEntityParam.kt b/api/src/main/kotlin/nebulosa/api/beans/converters/device/DeviceOrEntityParam.kt index 620851126..0d7e2970b 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/device/DeviceOrEntityParam.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/converters/device/DeviceOrEntityParam.kt @@ -1,5 +1,8 @@ package nebulosa.api.beans.converters.device -@Retention @Target(AnnotationTarget.VALUE_PARAMETER) -annotation class DeviceOrEntityParam(val required: Boolean = true) +@Retention(AnnotationRetention.RUNTIME) +annotation class DeviceOrEntityParam( + val name: String = "", + val defaultValue: String = "" +) diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/device/DeviceOrEntityParamMethodArgumentResolver.kt b/api/src/main/kotlin/nebulosa/api/beans/converters/device/DeviceOrEntityParamMethodArgumentResolver.kt index 85c6c2eff..230794519 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/device/DeviceOrEntityParamMethodArgumentResolver.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/converters/device/DeviceOrEntityParamMethodArgumentResolver.kt @@ -4,6 +4,8 @@ import nebulosa.api.atlas.SatelliteEntity import nebulosa.api.atlas.SatelliteRepository import nebulosa.api.beans.converters.annotation import nebulosa.api.beans.converters.parameter +import nebulosa.api.calibration.CalibrationFrameEntity +import nebulosa.api.calibration.CalibrationFrameRepository import nebulosa.api.connection.ConnectionService import nebulosa.indi.device.Device import nebulosa.indi.device.camera.Camera @@ -11,29 +13,30 @@ import nebulosa.indi.device.filterwheel.FilterWheel import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.guide.GuideOutput import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.rotator.Rotator import org.springframework.core.MethodParameter -import org.springframework.http.HttpStatus import org.springframework.stereotype.Component -import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.support.WebDataBinderFactory import org.springframework.web.context.request.NativeWebRequest import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.ModelAndViewContainer -import org.springframework.web.server.ResponseStatusException @Component class DeviceOrEntityParamMethodArgumentResolver( private val satelliteRepository: SatelliteRepository, + private val calibrationFrameRepository: CalibrationFrameRepository, private val connectionService: ConnectionService, ) : HandlerMethodArgumentResolver { private val entityResolvers = mapOf, (String) -> Any?>( SatelliteEntity::class.java to { satelliteRepository.find(it.toLong()) }, + CalibrationFrameEntity::class.java to { calibrationFrameRepository.find(it.toLong()) }, Device::class.java to { connectionService.device(it) }, Camera::class.java to { connectionService.camera(it) }, Mount::class.java to { connectionService.mount(it) }, Focuser::class.java to { connectionService.focuser(it) }, FilterWheel::class.java to { connectionService.wheel(it) }, + Rotator::class.java to { connectionService.rotator(it) }, GuideOutput::class.java to { connectionService.guideOutput(it) }, ) @@ -47,18 +50,10 @@ class DeviceOrEntityParamMethodArgumentResolver( webRequest: NativeWebRequest, binderFactory: WebDataBinderFactory?, ): Any? { - val requestParam = parameter.annotation() + val requestParam = parameter.annotation() val parameterName = requestParam?.name?.ifBlank { null } ?: parameter.parameterName ?: "id" - val parameterValue = webRequest.parameter(parameterName) ?: requestParam?.defaultValue - - val entity = entityByParameterValue(parameter.parameterType, parameterValue) - - if (requestParam != null && requestParam.required && entity == null) { - val message = "Cannot found a ${parameter.parameterType.simpleName} entity with name [$parameterValue]" - throw ResponseStatusException(HttpStatus.NOT_FOUND, message) - } - - return entity + val parameterValue = webRequest.parameter(parameterName) ?: requestParam?.defaultValue?.ifBlank { null } + return entityByParameterValue(parameter.parameterType, parameterValue) } private fun entityByParameterValue(parameterType: Class<*>, parameterValue: String?): Any? { diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/time/DurationInMicrosecondsSerializer.kt b/api/src/main/kotlin/nebulosa/api/beans/converters/time/DurationInMicrosecondsSerializer.kt deleted file mode 100644 index 12d4ce8b5..000000000 --- a/api/src/main/kotlin/nebulosa/api/beans/converters/time/DurationInMicrosecondsSerializer.kt +++ /dev/null @@ -1,15 +0,0 @@ -package nebulosa.api.beans.converters.time - -import com.fasterxml.jackson.core.JsonGenerator -import com.fasterxml.jackson.databind.SerializerProvider -import com.fasterxml.jackson.databind.ser.std.StdSerializer -import org.springframework.stereotype.Component -import java.time.Duration - -@Component -class DurationInMicrosecondsSerializer : StdSerializer(Duration::class.java) { - - override fun serialize(value: Duration?, gen: JsonGenerator, provider: SerializerProvider) { - value?.also { gen.writeNumber(it.toNanos() / 1000) } ?: gen.writeNull() - } -} diff --git a/api/src/main/kotlin/nebulosa/api/beans/converters/time/DurationSerializer.kt b/api/src/main/kotlin/nebulosa/api/beans/converters/time/DurationSerializer.kt new file mode 100644 index 000000000..de45953de --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/beans/converters/time/DurationSerializer.kt @@ -0,0 +1,37 @@ +package nebulosa.api.beans.converters.time + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.BeanProperty +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.ContextualSerializer +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.convert.DurationUnit +import org.springframework.stereotype.Component +import java.time.Duration +import java.time.temporal.ChronoUnit + +@Component +class DurationSerializer(private val unit: ChronoUnit?) : StdSerializer(Duration::class.java), ContextualSerializer { + + @Autowired + constructor() : this(null) + + override fun serialize(duration: Duration?, gen: JsonGenerator, provider: SerializerProvider) { + if (duration == null) gen.writeNull() + else if (unit != null) gen.writeNumber(duration.toNanos() / unit.duration.toNanos()) + else gen.writeNumber(duration.toNanos() / 1000) + } + + override fun createContextual(provider: SerializerProvider, property: BeanProperty): JsonSerializer<*> { + val unit = property.getAnnotation(DurationUnit::class.java)?.value ?: return this + return SERIALIZERS.getOrPut(unit) { DurationSerializer(unit) } + + } + + companion object { + + @JvmStatic private val SERIALIZERS = mutableMapOf() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/beans/EventBusBeanPostProcessor.kt b/api/src/main/kotlin/nebulosa/api/beans/processors/EventBusBeanPostProcessor.kt similarity index 95% rename from api/src/main/kotlin/nebulosa/api/beans/EventBusBeanPostProcessor.kt rename to api/src/main/kotlin/nebulosa/api/beans/processors/EventBusBeanPostProcessor.kt index 40788a745..b76db98d6 100644 --- a/api/src/main/kotlin/nebulosa/api/beans/EventBusBeanPostProcessor.kt +++ b/api/src/main/kotlin/nebulosa/api/beans/processors/EventBusBeanPostProcessor.kt @@ -1,4 +1,4 @@ -package nebulosa.api.beans +package nebulosa.api.beans.processors import nebulosa.api.beans.annotations.Subscriber import org.greenrobot.eventbus.EventBus diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameController.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameController.kt index c52f57053..199bf8db7 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameController.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameController.kt @@ -1,37 +1,41 @@ package nebulosa.api.calibration -import nebulosa.api.beans.converters.device.DeviceOrEntityParam -import nebulosa.indi.device.camera.Camera +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.* import java.nio.file.Path +@Validated @RestController @RequestMapping("calibration-frames") class CalibrationFrameController( private val calibrationFrameService: CalibrationFrameService, ) { - @GetMapping("{camera}") - fun groups(@DeviceOrEntityParam camera: Camera): List { + @GetMapping + fun groups() = calibrationFrameService.groups() + + @GetMapping("{name}") + fun groupedCalibrationFrames(@PathVariable name: String): List { var id = 0 - val groupedFrames = calibrationFrameService.groupedCalibrationFrames(camera.name) - return groupedFrames.map { CalibrationFrameGroup(id++, it.key, it.value) } + val groupedFrames = calibrationFrameService.groupedCalibrationFrames(name) + return groupedFrames.map { CalibrationFrameGroup(++id, name, it.key, it.value) } } - @PutMapping("{camera}") - fun upload(@DeviceOrEntityParam camera: Camera, @RequestParam path: Path): List { - return calibrationFrameService.upload(camera.name, path) + @PutMapping("{name}") + fun upload(@PathVariable name: String, @RequestParam path: Path): List { + return calibrationFrameService.upload(name, path) } - @PatchMapping("{id}") + @PatchMapping("{frame}") fun edit( - @PathVariable id: Long, - @RequestParam(required = false) path: String? = "", - @RequestParam enabled: Boolean, - ) = calibrationFrameService.edit(id, path, enabled) + frame: CalibrationFrameEntity, + @Valid @NotBlank @RequestParam name: String, @RequestParam enabled: Boolean, + ) = calibrationFrameService.edit(frame, name, enabled) - @DeleteMapping("{id}") - fun delete(@PathVariable id: Long) { - calibrationFrameService.delete(id) + @DeleteMapping("{frame}") + fun delete(frame: CalibrationFrameEntity) { + calibrationFrameService.delete(frame) } } diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt index 9bf5519b2..cfc214269 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt @@ -2,15 +2,17 @@ package nebulosa.api.calibration import io.objectbox.annotation.* import nebulosa.api.beans.converters.database.FrameTypePropertyConverter +import nebulosa.api.beans.converters.database.PathPropertyConverter import nebulosa.api.database.BoxEntity import nebulosa.indi.device.camera.FrameType +import java.nio.file.Path @Entity data class CalibrationFrameEntity( @Id override var id: Long = 0L, @Index @Convert(converter = FrameTypePropertyConverter::class, dbType = Int::class) var type: FrameType = FrameType.LIGHT, - @Index var camera: String? = null, - @Index var filter: String? = null, + @Index var name: String = "", + var filter: String? = null, var exposureTime: Long = 0L, var temperature: Double = 0.0, var width: Int = 0, @@ -18,6 +20,6 @@ data class CalibrationFrameEntity( var binX: Int = 0, var binY: Int = 0, var gain: Double = 0.0, - @Unique var path: String? = null, + @Convert(converter = PathPropertyConverter::class, dbType = String::class) var path: Path? = null, var enabled: Boolean = true, ) : BoxEntity diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameGroup.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameGroup.kt index 1320e3499..7e4171cda 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameGroup.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameGroup.kt @@ -2,6 +2,7 @@ package nebulosa.api.calibration data class CalibrationFrameGroup( val id: Int, + val name: String, val key: CalibrationGroupKey, val frames: List, ) diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameRepository.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameRepository.kt index 8db72ad5a..10399bf92 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameRepository.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameRepository.kt @@ -13,27 +13,29 @@ import org.springframework.stereotype.Component class CalibrationFrameRepository(@Qualifier("calibrationFrameBox") override val box: Box) : BoxRepository() { - fun findAll(camera: String): List { + fun groups() = box.all.map { it.name }.distinct() + + fun findAll(name: String): List { return box.query() - .equal(CalibrationFrameEntity_.camera, camera, CASE_SENSITIVE) + .equal(CalibrationFrameEntity_.name, name, CASE_SENSITIVE) .build() .use { it.find() } } @Synchronized - fun delete(camera: String, path: String) { + fun delete(name: String, path: String) { return box.query() - .equal(CalibrationFrameEntity_.camera, camera, CASE_SENSITIVE) + .equal(CalibrationFrameEntity_.name, name, CASE_SENSITIVE) .equal(CalibrationFrameEntity_.path, path, CASE_SENSITIVE) .build() .use { it.remove() } } - fun darkFrames(camera: String, width: Int, height: Int, bin: Int, exposureTime: Long, gain: Double): List { + fun darkFrames(name: String, width: Int, height: Int, bin: Int, exposureTime: Long, gain: Double): List { return box.query() .equal(CalibrationFrameEntity_.type, FrameType.DARK.ordinal) .equal(CalibrationFrameEntity_.enabled, true) - .equal(CalibrationFrameEntity_.camera, camera, CASE_SENSITIVE) + .equal(CalibrationFrameEntity_.name, name, CASE_SENSITIVE) .equal(CalibrationFrameEntity_.width, width) .equal(CalibrationFrameEntity_.height, height) .equal(CalibrationFrameEntity_.binX, bin) @@ -44,11 +46,11 @@ class CalibrationFrameRepository(@Qualifier("calibrationFrameBox") override val .use { it.find() } } - fun biasFrames(camera: String, width: Int, height: Int, bin: Int, gain: Double): List { + fun biasFrames(name: String, width: Int, height: Int, bin: Int, gain: Double): List { return box.query() .equal(CalibrationFrameEntity_.type, FrameType.BIAS.ordinal) .equal(CalibrationFrameEntity_.enabled, true) - .equal(CalibrationFrameEntity_.camera, camera, CASE_SENSITIVE) + .equal(CalibrationFrameEntity_.name, name, CASE_SENSITIVE) .equal(CalibrationFrameEntity_.width, width) .equal(CalibrationFrameEntity_.height, height) .equal(CalibrationFrameEntity_.binX, bin) @@ -58,11 +60,11 @@ class CalibrationFrameRepository(@Qualifier("calibrationFrameBox") override val .use { it.find() } } - fun flatFrames(camera: String, filter: String?, width: Int, height: Int, bin: Int): List { + fun flatFrames(name: String, filter: String?, width: Int, height: Int, bin: Int): List { return box.query() .equal(CalibrationFrameEntity_.type, FrameType.FLAT.ordinal) .equal(CalibrationFrameEntity_.enabled, true) - .equal(CalibrationFrameEntity_.camera, camera, CASE_SENSITIVE) + .equal(CalibrationFrameEntity_.name, name, CASE_SENSITIVE) .also { if (filter.isNullOrBlank()) it.isNull(CalibrationFrameEntity_.filter) else it.equal(CalibrationFrameEntity_.filter, filter, CASE_INSENSITIVE) diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt index f2def0291..8188e1b0d 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt @@ -5,11 +5,12 @@ import nebulosa.image.Image import nebulosa.image.algorithms.transformation.correction.BiasSubtraction import nebulosa.image.algorithms.transformation.correction.DarkSubtraction import nebulosa.image.algorithms.transformation.correction.FlatCorrection -import nebulosa.image.format.Header import nebulosa.image.format.ImageHdu import nebulosa.image.format.ReadableHeader import nebulosa.indi.device.camera.FrameType import nebulosa.log.loggerFor +import nebulosa.xisf.isXisf +import nebulosa.xisf.xisf import org.springframework.stereotype.Service import java.nio.file.Path import java.util.* @@ -27,81 +28,88 @@ class CalibrationFrameService( private val calibrationFrameRepository: CalibrationFrameRepository, ) { - fun calibrate(camera: String, image: Image, createNew: Boolean = false): Image { - val darkFrame = findBestDarkFrames(camera, image).firstOrNull() - val biasFrame = findBestBiasFrames(camera, image).firstOrNull() - val flatFrame = findBestFlatFrames(camera, image).firstOrNull() - - return if (darkFrame != null || biasFrame != null || flatFrame != null) { - var transformedImage = if (createNew) image.clone() else image - var calibrationImage = Image(transformedImage.width, transformedImage.height, Header.Empty, transformedImage.mono) + fun calibrate(name: String, image: Image, createNew: Boolean = false): Image { + return synchronized(image) { + val darkFrame = findBestDarkFrames(name, image).firstOrNull() + val biasFrame = findBestBiasFrames(name, image).firstOrNull() + val flatFrame = findBestFlatFrames(name, image).firstOrNull() + + if (darkFrame != null || biasFrame != null || flatFrame != null) { + var transformedImage = if (createNew) image.clone() else image + + if (biasFrame != null) { + val calibrationImage = biasFrame.path!!.fits().use(Image::open) + transformedImage = transformedImage.transform(BiasSubtraction(calibrationImage)) + LOG.info("bias frame subtraction applied. frame={}", biasFrame) + } else { + LOG.info( + "no bias frames found. width={}, height={}, bin={}, gain={}", + image.width, image.height, image.header.binX, image.header.gain + ) + } - if (biasFrame != null) { - calibrationImage = biasFrame.path!!.fits().use(calibrationImage::load)!! - transformedImage = transformedImage.transform(BiasSubtraction(calibrationImage)) - LOG.info("bias frame subtraction applied. frame={}", biasFrame) - } else { - LOG.info( - "no bias frames found. width={}, height={}, bin={}, gain={}", - image.width, image.height, image.header.binX, image.header.gain - ) - } + if (darkFrame != null) { + val calibrationImage = darkFrame.path!!.fits().use(Image::open) + transformedImage = transformedImage.transform(DarkSubtraction(calibrationImage)) + LOG.info("dark frame subtraction applied. frame={}", darkFrame) + } else { + LOG.info( + "no dark frames found. width={}, height={}, bin={}, exposureTime={}, gain={}", + image.width, image.height, image.header.binX, image.header.exposureTimeInMicroseconds, image.header.gain + ) + } - if (darkFrame != null) { - calibrationImage = darkFrame.path!!.fits().use(calibrationImage::load)!! - transformedImage = transformedImage.transform(DarkSubtraction(calibrationImage)) - LOG.info("dark frame subtraction applied. frame={}", darkFrame) - } else { - LOG.info( - "no dark frames found. width={}, height={}, bin={}, exposureTime={}, gain={}", - image.width, image.height, image.header.binX, image.header.exposureTimeInMicroseconds, image.header.gain - ) - } + if (flatFrame != null) { + val calibrationImage = flatFrame.path!!.fits().use(Image::open) + transformedImage = transformedImage.transform(FlatCorrection(calibrationImage)) + LOG.info("flat frame correction applied. frame={}", flatFrame) + } else { + LOG.info( + "no flat frames found. filter={}, width={}, height={}, bin={}", + image.header.filter, image.width, image.height, image.header.binX + ) + } - if (flatFrame != null) { - calibrationImage = flatFrame.path!!.fits().use(calibrationImage::load)!! - transformedImage = transformedImage.transform(FlatCorrection(calibrationImage)) - LOG.info("flat frame correction applied. frame={}", flatFrame) + transformedImage } else { LOG.info( - "no flat frames found. filter={}, width={}, height={}, bin={}", - image.header.filter, image.width, image.height, image.header.binX + "no calibration frames found. width={}, height={}, bin={}, gain={}, filter={}, exposureTime={}", + image.width, image.height, image.header.binX, image.header.gain, image.header.filter, image.header.exposureTimeInMicroseconds ) + image } - - transformedImage - } else { - LOG.info( - "no calibration frames found. width={}, height={}, bin={}, gain={}, filter={}, exposureTime={}", - image.width, image.height, image.header.binX, image.header.gain, image.header.filter, image.header.exposureTimeInMicroseconds - ) - image } } - fun groupedCalibrationFrames(camera: String): Map> { - val frames = calibrationFrameRepository.findAll(camera) + fun groups() = calibrationFrameRepository.groups() + + fun groupedCalibrationFrames(name: String): Map> { + val frames = calibrationFrameRepository.findAll(name) return frames.groupBy(CalibrationGroupKey::from) } - fun upload(camera: String, path: Path): List { + fun upload(name: String, path: Path): List { val files = if (path.isRegularFile() && path.isFits) listOf(path) - else if (path.isDirectory()) path.listDirectoryEntries("*.{fits,fit}").filter { it.isRegularFile() } + else if (path.isDirectory()) path.listDirectoryEntries("*.{fits,fit,xisf}").filter { it.isRegularFile() } else return emptyList() - return upload(camera, files) + return upload(name, files) } @Synchronized - fun upload(camera: String, files: List): List { + fun upload(name: String, files: List): List { val frames = ArrayList(files.size) for (file in files) { - calibrationFrameRepository.delete(camera, "$file") + calibrationFrameRepository.delete(name, "$file") try { - file.fits().use { fits -> - val hdu = fits.filterIsInstance().firstOrNull() ?: return@use + val image = if (file.isFits()) file.fits() + else if (file.isXisf()) file.xisf() + else continue + + image.use { + val hdu = image.filterIsInstance().firstOrNull() ?: return@use val header = hdu.header val frameType = header.frameType?.takeIf { it != FrameType.LIGHT } ?: return@use @@ -111,10 +119,10 @@ class CalibrationFrameService( val filter = if (frameType == FrameType.FLAT) header.filter else null val frame = CalibrationFrameEntity( - 0L, frameType, camera, filter, + 0L, frameType, name, filter, exposureTime, temperature, header.width, header.height, header.binX, header.binY, - gain, "$file", + gain, file, ) calibrationFrameRepository.save(frame) @@ -128,25 +136,23 @@ class CalibrationFrameService( return frames } - fun edit(id: Long, path: String?, enabled: Boolean): CalibrationFrameEntity { - return with(calibrationFrameRepository.find(id)!!) { - if (!path.isNullOrBlank()) this.path = path - this.enabled = enabled - calibrationFrameRepository.save(this) - } + fun edit(frame: CalibrationFrameEntity, name: String, enabled: Boolean): CalibrationFrameEntity { + frame.name = name + frame.enabled = enabled + return calibrationFrameRepository.save(frame) } - fun delete(id: Long) { - calibrationFrameRepository.delete(id) + fun delete(frame: CalibrationFrameEntity) { + calibrationFrameRepository.delete(frame) } // exposureTime, temperature, width, height, binX, binY, gain. - fun findBestDarkFrames(camera: String, image: Image): List { + fun findBestDarkFrames(name: String, image: Image): List { val header = image.header val temperature = header.temperature val frames = calibrationFrameRepository - .darkFrames(camera, image.width, image.height, header.binX, header.exposureTimeInMicroseconds, header.gain) + .darkFrames(name, image.width, image.height, header.binX, header.exposureTimeInMicroseconds, header.gain) if (frames.isEmpty()) return emptyList() @@ -159,19 +165,19 @@ class CalibrationFrameService( } // filter, width, height, binX, binY. - fun findBestFlatFrames(camera: String, image: Image): List { + fun findBestFlatFrames(name: String, image: Image): List { val filter = image.header.filter // TODO: Generate master from matched frames. return calibrationFrameRepository - .flatFrames(camera, filter, image.width, image.height, image.header.binX) + .flatFrames(name, filter, image.width, image.height, image.header.binX) } // width, height, binX, binY, gain. - fun findBestBiasFrames(camera: String, image: Image): List { + fun findBestBiasFrames(name: String, image: Image): List { // TODO: Generate master from matched frames. return calibrationFrameRepository - .biasFrames(camera, image.width, image.height, image.header.binX, image.header.gain) + .biasFrames(name, image.width, image.height, image.header.binX, image.header.gain) } companion object { diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureElapsed.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureElapsed.kt deleted file mode 100644 index 1e9121050..000000000 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureElapsed.kt +++ /dev/null @@ -1,37 +0,0 @@ -package nebulosa.api.cameras - -import nebulosa.api.messages.MessageEvent -import nebulosa.api.sequencer.JobExecutionEvent -import nebulosa.indi.device.camera.Camera -import java.nio.file.Path -import java.time.Duration - -sealed interface CameraCaptureElapsed : MessageEvent, JobExecutionEvent { - - val camera: Camera - - val state: CameraCaptureState - - val exposureAmount: Int - - val exposureCount: Int - - val captureElapsedTime: Duration - - val captureProgress: Double - - val captureRemainingTime: Duration - - val exposureProgress: Double - - val exposureRemainingTime: Duration - - val waitRemainingTime: Duration - - val waitProgress: Double - - val savePath: Path? - - override val eventName - get() = "CAMERA.CAPTURE_ELAPSED" -} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt new file mode 100644 index 000000000..805551724 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEvent.kt @@ -0,0 +1,30 @@ +package nebulosa.api.cameras + +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 val state: CameraCaptureState = CameraCaptureState.IDLE, + @JvmField val exposureAmount: Int = 0, + @JvmField val exposureCount: Int = 0, + @JvmField val captureRemainingTime: Duration = Duration.ZERO, + @JvmField val captureElapsedTime: Duration = Duration.ZERO, + @JvmField val captureProgress: Double = 0.0, + @JvmField val stepRemainingTime: Duration = Duration.ZERO, + @JvmField val stepElapsedTime: Duration = Duration.ZERO, + @JvmField val stepProgress: Double = 0.0, + @JvmField val savePath: 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/CameraCaptureEventHandler.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEventHandler.kt deleted file mode 100644 index 017228f44..000000000 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureEventHandler.kt +++ /dev/null @@ -1,96 +0,0 @@ -package nebulosa.api.cameras - -import io.reactivex.rxjava3.core.Observer -import nebulosa.api.guiding.WaitForSettleStep -import nebulosa.api.messages.MessageEvent -import nebulosa.batch.processing.ExecutionContext.Companion.getBoolean -import nebulosa.batch.processing.ExecutionContext.Companion.getDouble -import nebulosa.batch.processing.ExecutionContext.Companion.getDuration -import nebulosa.batch.processing.ExecutionContext.Companion.getInt -import nebulosa.batch.processing.JobExecution -import nebulosa.batch.processing.JobStatus -import nebulosa.batch.processing.StepExecution -import nebulosa.batch.processing.delay.DelayStep -import nebulosa.image.format.ImageRepresentation -import java.nio.file.Path - -data class CameraCaptureEventHandler(private val observer: Observer) : CameraCaptureListener { - - override fun onCaptureStarted(step: CameraExposureStep, jobExecution: JobExecution) { - observer.onNext(CameraCaptureStarted(jobExecution, step.camera, step.exposureAmount, step.estimatedCaptureTime, step.exposureTime)) - } - - override fun onExposureStarted(step: CameraExposureStep, stepExecution: StepExecution) { - sendCameraExposureEvent(step, stepExecution, CameraCaptureState.EXPOSURE_STARTED) - } - - override fun onExposureElapsed(step: CameraExposureStep, stepExecution: StepExecution) { - val waiting = stepExecution.context.getBoolean(DelayStep.WAITING) - val settling = stepExecution.context.getBoolean(WaitForSettleStep.WAITING) - val state = if (settling) CameraCaptureState.SETTLING - else if (waiting) CameraCaptureState.WAITING - else CameraCaptureState.EXPOSURING - sendCameraExposureEvent(step, stepExecution, state) - } - - override fun onExposureFinished(step: CameraExposureStep, stepExecution: StepExecution, image: ImageRepresentation?, savedPath: Path) { - sendCameraExposureEvent(step, stepExecution, CameraCaptureState.EXPOSURE_FINISHED, savedPath) - } - - override fun onCaptureFinished(step: CameraExposureStep, jobExecution: JobExecution) { - val captureElapsedTime = jobExecution.context.getDuration(CameraExposureStep.CAPTURE_ELAPSED_TIME) - val aborted = jobExecution.status == JobStatus.STOPPED || jobExecution.status == JobStatus.STOPPING - observer.onNext(CameraCaptureFinished(jobExecution, step.camera, step.exposureAmount, captureElapsedTime, aborted)) - } - - fun sendCameraExposureEvent(step: CameraExposureStep, stepExecution: StepExecution, state: CameraCaptureState, savedPath: Path? = null) { - val exposureCount = stepExecution.context.getInt(CameraExposureStep.EXPOSURE_COUNT) - val captureElapsedTime = stepExecution.context.getDuration(CameraExposureStep.CAPTURE_ELAPSED_TIME) - val captureProgress = stepExecution.context.getDouble(CameraExposureStep.CAPTURE_PROGRESS) - val captureRemainingTime = stepExecution.context.getDuration(CameraExposureStep.CAPTURE_REMAINING_TIME) - - val event = when (state) { - CameraCaptureState.WAITING, - CameraCaptureState.SETTLING -> { - val waitProgress = stepExecution.context.getDouble(DelayStep.PROGRESS) - val waitRemainingTime = stepExecution.context.getDuration(DelayStep.REMAINING_TIME) - - CameraCaptureIsWaiting( - stepExecution.jobExecution, step.camera, - step.exposureAmount, exposureCount, captureElapsedTime, captureProgress, captureRemainingTime, - waitProgress, waitRemainingTime, state - ) - } - CameraCaptureState.EXPOSURING -> { - val exposureProgress = stepExecution.context.getDouble(CameraExposureStep.EXPOSURE_PROGRESS) - val exposureRemainingTime = stepExecution.context.getDuration(CameraExposureStep.EXPOSURE_REMAINING_TIME) - - CameraExposureElapsed( - stepExecution.jobExecution, step.camera, - step.exposureAmount, exposureCount, captureElapsedTime, captureProgress, captureRemainingTime, - exposureProgress, exposureRemainingTime - ) - } - CameraCaptureState.EXPOSURE_STARTED -> { - val exposureRemainingTime = stepExecution.context.getDuration(CameraExposureStep.EXPOSURE_REMAINING_TIME) - - CameraExposureStarted( - stepExecution.jobExecution, step.camera, - step.exposureAmount, exposureCount, captureElapsedTime, - captureProgress, captureRemainingTime, exposureRemainingTime - ) - } - CameraCaptureState.EXPOSURE_FINISHED -> { - CameraExposureFinished( - stepExecution.jobExecution, step.camera, - step.exposureAmount, exposureCount, - captureElapsedTime, captureProgress, captureRemainingTime, - savedPath!!, - ) - } - else -> return - } - - observer.onNext(event) - } -} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt index 7d0b97eb8..ca75076ef 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureExecutor.kt @@ -1,39 +1,56 @@ package nebulosa.api.cameras +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.beans.annotations.Subscriber import nebulosa.api.messages.MessageService -import nebulosa.batch.processing.JobExecutor -import nebulosa.batch.processing.JobLauncher import nebulosa.guiding.Guider import nebulosa.indi.device.camera.Camera -import nebulosa.log.info -import nebulosa.log.loggerFor +import nebulosa.indi.device.camera.CameraEvent +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor import org.springframework.stereotype.Component +import java.util.concurrent.ConcurrentHashMap @Component +@Subscriber class CameraCaptureExecutor( private val messageService: MessageService, private val guider: Guider, - override val jobLauncher: JobLauncher, -) : JobExecutor() { + private val threadPoolTaskExecutor: ThreadPoolTaskExecutor, +) : Consumer { - fun execute(camera: Camera, request: CameraStartCaptureRequest): String { - check(camera.connected) { "camera is not connected" } - check(findJobExecutionWithAny(camera) == null) { "Camera Capture job is already running" } + private val jobs = ConcurrentHashMap.newKeySet(2) - LOG.info { "starting camera capture. camera=$camera, request=$request" } + @Subscribe(threadMode = ThreadMode.ASYNC) + fun onCameraEvent(event: CameraEvent) { + jobs.find { it.task.camera === event.device }?.handleCameraEvent(event) + } - val cameraCaptureJob = CameraCaptureJob(camera, request, guider) - cameraCaptureJob.subscribe(messageService::sendMessage) - register(jobLauncher.launch(cameraCaptureJob)) - return cameraCaptureJob.id + override fun accept(event: CameraCaptureEvent) { + messageService.sendMessage(event) } - fun stop(camera: Camera) { - findJobExecutionWithAny(camera)?.also { jobLauncher.stop(it) } + @Synchronized + fun execute(camera: Camera, request: CameraStartCaptureRequest) { + check(camera.connected) { "${camera.name} Camera is not connected" } + check(jobs.none { it.task.camera === camera }) { "${camera.name} Camera Capture is already in progress" } + + val task = CameraCaptureTask(camera, request, guider, executor = threadPoolTaskExecutor) + task.subscribe(this) + + with(CameraCaptureJob(task)) { + jobs.add(this) + whenComplete { _, _ -> jobs.remove(this) } + start() + } } - companion object { + fun stop(camera: Camera) { + jobs.find { it.task.camera === camera }?.stop() + } - @JvmStatic private val LOG = loggerFor() + fun status(camera: Camera): CameraCaptureEvent? { + return jobs.find { it.task.camera === camera }?.task?.get() } } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureFinished.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureFinished.kt deleted file mode 100644 index 522733948..000000000 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureFinished.kt +++ /dev/null @@ -1,25 +0,0 @@ -package nebulosa.api.cameras - -import com.fasterxml.jackson.annotation.JsonIgnore -import nebulosa.batch.processing.JobExecution -import nebulosa.indi.device.camera.Camera -import java.time.Duration - -data class CameraCaptureFinished( - @JsonIgnore override val jobExecution: JobExecution, - override val camera: Camera, - override val exposureAmount: Int, - override val captureElapsedTime: Duration, - val aborted: Boolean, -) : CameraCaptureElapsed { - - override val exposureCount = exposureAmount - override val captureProgress = 1.0 - override val captureRemainingTime = Duration.ZERO!! - override val exposureProgress = 1.0 - override val exposureRemainingTime = Duration.ZERO!! - override val state = CameraCaptureState.CAPTURE_FINISHED - override val waitRemainingTime = Duration.ZERO!! - override val waitProgress = 0.0 - override val savePath = null -} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureIsWaiting.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureIsWaiting.kt deleted file mode 100644 index 5e6cb8c51..000000000 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureIsWaiting.kt +++ /dev/null @@ -1,24 +0,0 @@ -package nebulosa.api.cameras - -import com.fasterxml.jackson.annotation.JsonIgnore -import nebulosa.batch.processing.JobExecution -import nebulosa.indi.device.camera.Camera -import java.time.Duration - -data class CameraCaptureIsWaiting( - @JsonIgnore override val jobExecution: JobExecution, - override val camera: Camera, - override val exposureAmount: Int, - override val exposureCount: Int, - override val captureElapsedTime: Duration, - override val captureProgress: Double, - override val captureRemainingTime: Duration, - override val waitProgress: Double, - override val waitRemainingTime: Duration, - override val state: CameraCaptureState = CameraCaptureState.WAITING, -) : CameraCaptureElapsed { - - override val exposureProgress = 1.0 - override val exposureRemainingTime = Duration.ZERO!! - override val savePath = null -} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt index 9f0337f2f..584e66013 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureJob.kt @@ -1,55 +1,13 @@ package nebulosa.api.cameras -import io.reactivex.rxjava3.subjects.PublishSubject -import nebulosa.api.guiding.DitherAfterExposureStep -import nebulosa.api.guiding.WaitForSettleStep -import nebulosa.api.messages.MessageEvent -import nebulosa.batch.processing.PublishSubscribe -import nebulosa.batch.processing.SimpleJob -import nebulosa.batch.processing.SimpleSplitStep -import nebulosa.batch.processing.delay.DelayStep -import nebulosa.guiding.Guider -import nebulosa.indi.device.camera.Camera +import nebulosa.api.tasks.Job +import nebulosa.indi.device.camera.CameraEvent -data class CameraCaptureJob( - @JvmField val camera: Camera, - @JvmField val request: CameraStartCaptureRequest, - @JvmField val guider: Guider, -) : SimpleJob(), PublishSubscribe { +data class CameraCaptureJob(override val task: CameraCaptureTask) : Job() { - private val cameraCaptureEventHandler = CameraCaptureEventHandler(this) + override val name = "${task.camera.name} Camera Capture Job" - override val subject = PublishSubject.create() - - init { - val cameraExposureStep = if (request.isLoop) CameraLoopExposureStep(camera, request) - else CameraExposureStep(camera, request) - - if (cameraExposureStep is CameraExposureStep) { - val ditherStep = DitherAfterExposureStep(request.dither, guider) - val waitForSettleStep = WaitForSettleStep(guider) - val cameraDelayStep = DelayStep(request.exposureDelay) - val delayAndWaitForSettleStep = SimpleSplitStep(cameraDelayStep, waitForSettleStep) - - waitForSettleStep.registerWaitForSettleListener(cameraExposureStep) - cameraDelayStep.registerDelayStepListener(cameraExposureStep) - - register(waitForSettleStep) - register(cameraExposureStep) - - repeat(request.exposureAmount - 1) { - register(delayAndWaitForSettleStep) - register(cameraExposureStep) - register(ditherStep) - } - } else { - register(cameraExposureStep) - } - - cameraExposureStep.registerCameraCaptureListener(cameraCaptureEventHandler) - } - - override fun contains(data: Any): Boolean { - return data === camera || super.contains(data) + fun handleCameraEvent(event: CameraEvent) { + task.handleCameraEvent(event) } } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureListener.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureListener.kt deleted file mode 100644 index 770003980..000000000 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureListener.kt +++ /dev/null @@ -1,19 +0,0 @@ -package nebulosa.api.cameras - -import nebulosa.batch.processing.JobExecution -import nebulosa.batch.processing.StepExecution -import nebulosa.image.format.ImageRepresentation -import java.nio.file.Path - -interface CameraCaptureListener { - - fun onCaptureStarted(step: CameraExposureStep, jobExecution: JobExecution) = Unit - - fun onExposureStarted(step: CameraExposureStep, stepExecution: StepExecution) = Unit - - fun onExposureElapsed(step: CameraExposureStep, stepExecution: StepExecution) = Unit - - fun onExposureFinished(step: CameraExposureStep, stepExecution: StepExecution, image: ImageRepresentation?, savedPath: Path) = Unit - - fun onCaptureFinished(step: CameraExposureStep, jobExecution: JobExecution) = Unit -} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureStarted.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureStarted.kt deleted file mode 100644 index d17db6ad0..000000000 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureStarted.kt +++ /dev/null @@ -1,24 +0,0 @@ -package nebulosa.api.cameras - -import com.fasterxml.jackson.annotation.JsonIgnore -import nebulosa.batch.processing.JobExecution -import nebulosa.indi.device.camera.Camera -import java.time.Duration - -data class CameraCaptureStarted( - @JsonIgnore override val jobExecution: JobExecution, - override val camera: Camera, - override val exposureAmount: Int, - override val captureRemainingTime: Duration, - override val exposureRemainingTime: Duration, -) : CameraCaptureElapsed { - - override val exposureCount = 1 - override val captureElapsedTime = Duration.ZERO!! - override val captureProgress = 0.0 - override val exposureProgress = 0.0 - override val state = CameraCaptureState.CAPTURE_STARTED - override val waitRemainingTime = Duration.ZERO!! - override val waitProgress = 0.0 - override val savePath = null -} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureState.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureState.kt index b64506b33..4052d019a 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureState.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureState.kt @@ -1,11 +1,13 @@ package nebulosa.api.cameras enum class CameraCaptureState { + IDLE, CAPTURE_STARTED, EXPOSURE_STARTED, EXPOSURING, WAITING, SETTLING, + DITHERING, EXPOSURE_FINISHED, CAPTURE_FINISHED, } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureStatus.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureStatus.kt deleted file mode 100644 index d8b8eb819..000000000 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureStatus.kt +++ /dev/null @@ -1,7 +0,0 @@ -package nebulosa.api.cameras - -enum class CameraCaptureStatus { - IDLE, - CAPTURING, - WAITING, -} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt new file mode 100644 index 000000000..79bf64ffc --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt @@ -0,0 +1,209 @@ +package nebulosa.api.cameras + +import com.fasterxml.jackson.annotation.JsonIgnore +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.guiding.DitherAfterExposureTask +import nebulosa.api.guiding.WaitForSettleTask +import nebulosa.api.tasks.AbstractTask +import nebulosa.api.tasks.SplitTask +import nebulosa.api.tasks.delay.DelayEvent +import nebulosa.api.tasks.delay.DelayTask +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.guiding.Guider +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.camera.CameraEvent +import nebulosa.log.loggerFor +import java.nio.file.Path +import java.time.Duration +import java.util.concurrent.Executor + +data class CameraCaptureTask( + @JvmField val camera: Camera, + @JvmField val request: CameraStartCaptureRequest, + @JvmField val guider: Guider? = null, + private val useFirstExposure: Boolean = false, + private val exposureMaxRepeat: Int = 0, + private val executor: Executor? = null, +) : AbstractTask(), Consumer { + + private val delayTask = DelayTask(request.exposureDelay) + private val waitForSettleTask = WaitForSettleTask(guider) + private val delayAndWaitForSettleSplitTask = SplitTask(listOf(delayTask, waitForSettleTask), executor) + 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 + @Volatile private var captureElapsedTime = Duration.ZERO + @Volatile private var captureProgress = 0.0 + @Volatile private var stepRemainingTime = Duration.ZERO + @Volatile private var stepElapsedTime = Duration.ZERO + @Volatile private var stepProgress = 0.0 + @Volatile private var savePath: 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 + + init { + delayTask.subscribe(this) + cameraExposureTask.subscribe(this) + + if (guider != null) { + // waitForSettleTask.subscribe(this) + ditherAfterExposureTask.subscribe(this) + } + } + + fun handleCameraEvent(event: CameraEvent) { + cameraExposureTask.handleCameraEvent(event) + } + + override fun execute(cancellationToken: CancellationToken) { + LOG.info("Camera Capture started. camera={}, request={}, exposureCount={}", camera, request, exposureCount) + + cameraExposureTask.reset() + + while (!cancellationToken.isDone && + !cameraExposureTask.isAborted && + ((exposureMaxRepeat > 0 && exposureRepeatCount < exposureMaxRepeat) + || (exposureMaxRepeat <= 0 && (request.isLoop || exposureCount < request.exposureAmount))) + ) { + if (exposureCount == 0) { + state = CameraCaptureState.CAPTURE_STARTED + sendEvent() + + if (guider != null) { + if (useFirstExposure) { + // DELAY & WAIT FOR SETTLE. + delayAndWaitForSettleSplitTask.execute(cancellationToken) + } else { + // WAIT FOR SETTLE. + waitForSettleTask.execute(cancellationToken) + } + } else if (useFirstExposure) { + // DELAY. + delayTask.execute(cancellationToken) + } + } else if (guider != null) { + // DELAY & WAIT FOR SETTLE. + delayAndWaitForSettleSplitTask.execute(cancellationToken) + } else { + // DELAY. + delayTask.execute(cancellationToken) + } + + // CAPTURE. + cameraExposureTask.execute(cancellationToken) + + // DITHER. + if (!cancellationToken.isDone && !cameraExposureTask.isAborted && guider != null + && exposureCount >= 1 && exposureCount % request.dither.afterExposures == 0 + ) { + ditherAfterExposureTask.execute(cancellationToken) + } + } + + if (state != CameraCaptureState.CAPTURE_FINISHED) { + state = CameraCaptureState.CAPTURE_FINISHED + sendEvent() + } + + exposureRepeatCount = 0 + + LOG.info("Camera Capture finished. camera={}, request={}, exposureCount={}", camera, request, exposureCount) + } + + @Synchronized + override fun accept(event: Any) { + when (event) { + is DelayEvent -> { + state = CameraCaptureState.WAITING + captureElapsedTime += event.waitTime + stepElapsedTime = event.task.duration - event.remainingTime + stepRemainingTime = event.remainingTime + stepProgress = event.progress + } + is CameraExposureEvent -> { + when (event.state) { + CameraExposureState.STARTED -> { + state = CameraCaptureState.EXPOSURE_STARTED + prevCaptureElapsedTime = captureElapsedTime + exposureCount++ + exposureRepeatCount++ + } + CameraExposureState.ELAPSED -> { + state = CameraCaptureState.EXPOSURING + captureElapsedTime = prevCaptureElapsedTime + event.elapsedTime + stepElapsedTime = event.elapsedTime + stepRemainingTime = event.remainingTime + stepProgress = event.progress + } + CameraExposureState.FINISHED -> { + state = CameraCaptureState.EXPOSURE_FINISHED + captureElapsedTime = prevCaptureElapsedTime + request.exposureTime + savePath = event.savedPath + } + CameraExposureState.IDLE -> { + state = CameraCaptureState.CAPTURE_FINISHED + } + } + } + else -> return LOG.warn("unknown event: {}", event) + } + + sendEvent() + } + + private fun sendEvent() { + 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, + captureRemainingTime, captureElapsedTime, captureProgress, + stepRemainingTime, stepElapsedTime, stepProgress, + savePath + ) + + onNext(event) + } + + override fun close() { + delayTask.close() + waitForSettleTask.close() + delayAndWaitForSettleSplitTask.close() + cameraExposureTask.close() + ditherAfterExposureTask.close() + super.close() + } + + override fun reset() { + state = CameraCaptureState.IDLE + exposureCount = 0 + captureRemainingTime = Duration.ZERO + prevCaptureElapsedTime = Duration.ZERO + captureElapsedTime = Duration.ZERO + captureProgress = 0.0 + stepRemainingTime = Duration.ZERO + stepElapsedTime = Duration.ZERO + stepProgress = 0.0 + savePath = null + + delayTask.reset() + cameraExposureTask.reset() + ditherAfterExposureTask.reset() + + exposureRepeatCount = 0 + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt index 7b283dcf5..7e64d19d7 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraController.kt @@ -1,12 +1,12 @@ package nebulosa.api.cameras import jakarta.validation.Valid -import nebulosa.api.beans.converters.device.DeviceOrEntityParam import nebulosa.api.connection.ConnectionService import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.filterwheel.FilterWheel import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.rotator.Rotator import org.hibernate.validator.constraints.Range import org.springframework.web.bind.annotation.* @@ -23,54 +23,51 @@ class CameraController( } @GetMapping("{camera}") - fun camera(@DeviceOrEntityParam camera: Camera): Camera { + fun camera(camera: Camera): Camera { return camera } @PutMapping("{camera}/connect") - fun connect(@DeviceOrEntityParam camera: Camera) { + fun connect(camera: Camera) { cameraService.connect(camera) } @PutMapping("{camera}/disconnect") - fun disconnect(@DeviceOrEntityParam camera: Camera) { + fun disconnect(camera: Camera) { cameraService.disconnect(camera) } @PutMapping("{camera}/snoop") fun snoop( - @DeviceOrEntityParam camera: Camera, - @DeviceOrEntityParam(required = false) mount: Mount?, - @DeviceOrEntityParam(required = false) wheel: FilterWheel?, - @DeviceOrEntityParam(required = false) focuser: Focuser?, - ) { - cameraService.snoop(camera, mount, wheel, focuser) - } + camera: Camera, + mount: Mount?, wheel: FilterWheel?, focuser: Focuser?, rotator: Rotator? + ) = cameraService.snoop(camera, mount, wheel, focuser, rotator) @PutMapping("{camera}/cooler") fun cooler( - @DeviceOrEntityParam camera: Camera, + camera: Camera, @RequestParam enabled: Boolean, - ) { - cameraService.cooler(camera, enabled) - } + ) = cameraService.cooler(camera, enabled) @PutMapping("{camera}/temperature/setpoint") fun setpointTemperature( - @DeviceOrEntityParam camera: Camera, + camera: Camera, @RequestParam @Valid @Range(min = -50, max = 50) temperature: Double, - ) { - cameraService.setpointTemperature(camera, temperature) - } + ) = cameraService.setpointTemperature(camera, temperature) @PutMapping("{camera}/capture/start") fun startCapture( - @DeviceOrEntityParam camera: Camera, + camera: Camera, @RequestBody body: CameraStartCaptureRequest, ) = cameraService.startCapture(camera, body) @PutMapping("{camera}/capture/abort") - fun abortCapture(@DeviceOrEntityParam camera: Camera) { + fun abortCapture(camera: Camera) { cameraService.abortCapture(camera) } + + @GetMapping("{camera}/capture/status") + fun statusCapture(camera: Camera): CameraCaptureEvent? { + return cameraService.statusCapture(camera) + } } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureElapsed.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureElapsed.kt deleted file mode 100644 index 705e84ab7..000000000 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureElapsed.kt +++ /dev/null @@ -1,24 +0,0 @@ -package nebulosa.api.cameras - -import com.fasterxml.jackson.annotation.JsonIgnore -import nebulosa.batch.processing.JobExecution -import nebulosa.indi.device.camera.Camera -import java.time.Duration - -data class CameraExposureElapsed( - @JsonIgnore override val jobExecution: JobExecution, - override val camera: Camera, - override val exposureAmount: Int, - override val exposureCount: Int, - override val captureElapsedTime: Duration, - override val captureProgress: Double, - override val captureRemainingTime: Duration, - override val exposureProgress: Double, - override val exposureRemainingTime: Duration, -) : CameraCaptureElapsed { - - override val state = CameraCaptureState.EXPOSURING - override val waitProgress = 0.0 - override val waitRemainingTime = Duration.ZERO!! - override val savePath = null -} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureEvent.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureEvent.kt new file mode 100644 index 000000000..8d721dafb --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureEvent.kt @@ -0,0 +1,13 @@ +package nebulosa.api.cameras + +import java.nio.file.Path +import java.time.Duration + +data class CameraExposureEvent( + @JvmField val task: CameraExposureTask, + @JvmField val state: CameraExposureState = CameraExposureState.IDLE, + @JvmField val elapsedTime: Duration = Duration.ZERO, + @JvmField val remainingTime: Duration = Duration.ZERO, + @JvmField val progress: Double = 0.0, + @JvmField val savedPath: Path? = null, +) diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureFinished.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureFinished.kt deleted file mode 100644 index 98a7cfe50..000000000 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureFinished.kt +++ /dev/null @@ -1,26 +0,0 @@ -package nebulosa.api.cameras - -import com.fasterxml.jackson.annotation.JsonIgnore -import nebulosa.batch.processing.JobExecution -import nebulosa.indi.device.camera.Camera -import java.nio.file.Path -import java.time.Duration - -data class CameraExposureFinished( - @JsonIgnore override val jobExecution: JobExecution, - override val camera: Camera, - override val exposureAmount: Int, - override val exposureCount: Int, - override val captureElapsedTime: Duration, - override val captureProgress: Double, - override val captureRemainingTime: Duration, - override val savePath: Path, -) : CameraCaptureElapsed { - - override val state = CameraCaptureState.EXPOSURE_FINISHED - override val exposureProgress = 0.0 - override val exposureRemainingTime = Duration.ZERO!! - override val waitProgress = 0.0 - override val waitRemainingTime = Duration.ZERO!! -} - diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStarted.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStarted.kt deleted file mode 100644 index f53797f7f..000000000 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStarted.kt +++ /dev/null @@ -1,24 +0,0 @@ -package nebulosa.api.cameras - -import com.fasterxml.jackson.annotation.JsonIgnore -import nebulosa.batch.processing.JobExecution -import nebulosa.indi.device.camera.Camera -import java.time.Duration - -data class CameraExposureStarted( - @JsonIgnore override val jobExecution: JobExecution, - override val camera: Camera, - override val exposureAmount: Int, - override val exposureCount: Int, - override val captureElapsedTime: Duration, - override val captureProgress: Double, - override val captureRemainingTime: Duration, - override val exposureRemainingTime: Duration, -) : CameraCaptureElapsed { - - override val state = CameraCaptureState.EXPOSURE_STARTED - override val exposureProgress = 0.0 - override val waitProgress = 0.0 - override val waitRemainingTime = Duration.ZERO!! - override val savePath = null -} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureState.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureState.kt new file mode 100644 index 000000000..3c71ba3ea --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureState.kt @@ -0,0 +1,8 @@ +package nebulosa.api.cameras + +enum class CameraExposureState { + IDLE, + STARTED, + ELAPSED, + FINISHED, +} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt deleted file mode 100644 index 3bc3ba3de..000000000 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureStep.kt +++ /dev/null @@ -1,243 +0,0 @@ -package nebulosa.api.cameras - -import nebulosa.api.guiding.WaitForSettleListener -import nebulosa.api.guiding.WaitForSettleStep -import nebulosa.batch.processing.ExecutionContext -import nebulosa.batch.processing.ExecutionContext.Companion.getDuration -import nebulosa.batch.processing.ExecutionContext.Companion.getInt -import nebulosa.batch.processing.JobExecution -import nebulosa.batch.processing.StepExecution -import nebulosa.batch.processing.StepResult -import nebulosa.batch.processing.delay.DelayStep -import nebulosa.batch.processing.delay.DelayStepListener -import nebulosa.common.concurrency.latch.CountUpDownLatch -import nebulosa.indi.device.camera.* -import nebulosa.io.transferAndClose -import nebulosa.log.debug -import nebulosa.log.loggerFor -import okio.sink -import org.greenrobot.eventbus.EventBus -import org.greenrobot.eventbus.Subscribe -import org.greenrobot.eventbus.ThreadMode -import java.nio.file.Path -import java.time.Duration -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import kotlin.io.path.createParentDirectories -import kotlin.io.path.outputStream - -data class CameraExposureStep( - override val camera: Camera, - override val request: CameraStartCaptureRequest, - private val virtualLoop: Boolean = false, -) : CameraStartCaptureStep, DelayStepListener, WaitForSettleListener { - - @JvmField val exposureTime = request.exposureTime - @JvmField val exposureAmount = request.exposureAmount - @JvmField val exposureDelay = request.exposureDelay - - @JvmField @Volatile var estimatedCaptureTime: Duration = if (request.isLoop) Duration.ZERO - else Duration.ofNanos(exposureTime.toNanos() * exposureAmount + exposureDelay.toNanos() * (exposureAmount - 1)) - - private val latch = CountUpDownLatch() - private val listeners = LinkedHashSet() - - @Volatile private var aborted = false - @Volatile private var exposureCount = 0 - @Volatile private var captureElapsedTime = Duration.ZERO!! - - @Volatile private var stepExecution: StepExecution? = null - - override fun registerCameraCaptureListener(listener: CameraCaptureListener): Boolean { - return listeners.add(listener) - } - - override fun unregisterCameraCaptureListener(listener: CameraCaptureListener): Boolean { - return listeners.remove(listener) - } - - @Subscribe(threadMode = ThreadMode.ASYNC) - fun onCameraEvent(event: CameraEvent) { - if (event.device === camera) { - when (event) { - is CameraFrameCaptured -> { - save(event) - } - is CameraExposureAborted, - is CameraExposureFailed, - is CameraDetached -> { - latch.reset() - aborted = true - } - is CameraExposureProgressChanged -> { - // minOf fix possible bug on SVBony exposure time. - val exposureRemainingTime = minOf(event.device.exposureTime, exposureTime) - val exposureElapsedTime = exposureTime - exposureRemainingTime - val exposureProgress = exposureElapsedTime.toNanos().toDouble() / exposureTime.toNanos() - stepExecution?.onCameraExposureElapsed(exposureElapsedTime, exposureRemainingTime, exposureProgress) - } - } - } - } - - override fun beforeJob(jobExecution: JobExecution) { - exposureCount = jobExecution.context.getInt(EXPOSURE_COUNT, exposureCount) - captureElapsedTime = jobExecution.context.getDuration(CAPTURE_ELAPSED_TIME, captureElapsedTime) - jobExecution.context.populateExecutionContext(Duration.ZERO, estimatedCaptureTime, 0.0) - listeners.forEach { it.onCaptureStarted(this, jobExecution) } - } - - override fun afterJob(jobExecution: JobExecution) { - // TODO: BUG: Está desativando para todas as cameras. Fiz alguma coisa errada ou isso é um bug? - // camera.disableBlob() - listeners.forEach { it.onCaptureFinished(this, jobExecution) } - listeners.clear() - } - - override fun execute(stepExecution: StepExecution): StepResult { - if (request.isLoop || estimatedCaptureTime > Duration.ZERO) { - this.stepExecution = stepExecution - EventBus.getDefault().register(this) - executeCapture(stepExecution) - EventBus.getDefault().unregister(this) - } - - return StepResult.FINISHED - } - - override fun stop(mayInterruptIfRunning: Boolean) { - LOG.info("stopping camera exposure. camera={}", camera) - camera.abortCapture() - // camera.disableBlob() - aborted = true - latch.reset() - } - - override fun onDelayElapsed(step: DelayStep, stepExecution: StepExecution) { - val waitTime = stepExecution.context.getDuration(DelayStep.WAIT_TIME) - captureElapsedTime += waitTime - stepExecution.onCameraExposureElapsed(Duration.ZERO, Duration.ZERO, 1.0) - } - - override fun onSettleStarted(step: WaitForSettleStep, stepExecution: StepExecution) { - stepExecution.onCameraExposureElapsed(Duration.ZERO, Duration.ZERO, 1.0) - } - - override fun onSettleFinished(step: WaitForSettleStep, stepExecution: StepExecution) { - stepExecution.onCameraExposureElapsed(Duration.ZERO, Duration.ZERO, 1.0) - } - - private fun executeCapture(stepExecution: StepExecution) { - if (camera.connected && !aborted) { - synchronized(camera) { - LOG.debug { "camera exposure started. estimatedCaptureTime=$estimatedCaptureTime, request=$request, context=${stepExecution.context}" } - - latch.countUp() - - stepExecution.context[EXPOSURE_COUNT] = ++exposureCount - - camera.enableBlob() - - listeners.forEach { it.onExposureStarted(this, stepExecution) } - - if (request.width > 0 && request.height > 0) { - camera.frame(request.x, request.y, request.width, request.height) - } - - camera.frameType(request.frameType) - camera.frameFormat(request.frameFormat) - camera.bin(request.binX, request.binY) - camera.gain(request.gain) - camera.offset(request.offset) - camera.startCapture(exposureTime) - - latch.await() - - captureElapsedTime += exposureTime - stepExecution.context[CAPTURE_ELAPSED_TIME] = captureElapsedTime - - LOG.debug { "camera exposure finished. aborted=$aborted, camera=$camera, context=${stepExecution.context}" } - } - } else { - LOG.warn("camera not connected or aborted. aborted=$aborted, camera=$camera") - } - } - - private fun save(event: CameraFrameCaptured) { - try { - val savedPath = request.makeSavePath(camera) - - LOG.info("saving FITS image at {}", savedPath) - - savedPath.createParentDirectories() - - if (event.stream != null) { - event.stream!!.transferAndClose(savedPath.outputStream()) - } else if (event.image != null) { - savedPath.sink().use(event.image!!::write) - } else { - LOG.warn("invalid event. camera={}", event.device) - return - } - - listeners.forEach { it.onExposureFinished(this, stepExecution!!, event.image, savedPath) } - } catch (e: Throwable) { - LOG.error("failed to save FITS image", e) - aborted = true - } finally { - latch.countDown() - } - } - - private fun StepExecution.onCameraExposureElapsed(elapsedTime: Duration, remainingTime: Duration, progress: Double) { - context.populateExecutionContext(elapsedTime, remainingTime, progress) - listeners.forEach { it.onExposureElapsed(this@CameraExposureStep, this) } - } - - private fun ExecutionContext.populateExecutionContext(elapsedTime: Duration, remainingTime: Duration, progress: Double) { - val captureElapsedTime = captureElapsedTime + elapsedTime - var captureRemainingTime = Duration.ZERO - var captureProgress = 0.0 - - if (!request.isLoop && !virtualLoop) { - captureRemainingTime = if (estimatedCaptureTime > captureElapsedTime) estimatedCaptureTime - captureElapsedTime else Duration.ZERO - captureProgress = (estimatedCaptureTime - captureRemainingTime).toNanos().toDouble() / estimatedCaptureTime.toNanos() - } - - this[EXPOSURE_ELAPSED_TIME] = elapsedTime - this[EXPOSURE_REMAINING_TIME] = remainingTime - this[EXPOSURE_PROGRESS] = progress - this[CAPTURE_ELAPSED_TIME] = captureElapsedTime - this[CAPTURE_REMAINING_TIME] = captureRemainingTime - this[CAPTURE_PROGRESS] = captureProgress - } - - companion object { - - const val EXPOSURE_COUNT = "CAMERA_EXPOSURE.EXPOSURE_COUNT" - const val EXPOSURE_ELAPSED_TIME = "CAMERA_EXPOSURE.EXPOSURE_ELAPSED_TIME" - const val EXPOSURE_REMAINING_TIME = "CAMERA_EXPOSURE.EXPOSURE_REMAINING_TIME" - const val EXPOSURE_PROGRESS = "CAMERA_EXPOSURE.EXPOSURE_PROGRESS" - const val CAPTURE_ELAPSED_TIME = "CAMERA_EXPOSURE.CAPTURE_ELAPSED_TIME" - const val CAPTURE_REMAINING_TIME = "CAMERA_EXPOSURE.CAPTURE_REMAINING_TIME" - const val CAPTURE_PROGRESS = "CAMERA_EXPOSURE.CAPTURE_PROGRESS" - - @JvmStatic private val LOG = loggerFor() - @JvmStatic private val DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd.HHmmssSSS") - - @JvmStatic - internal fun CameraStartCaptureRequest.makeSavePath( - camera: Camera, autoSave: Boolean = this.autoSave, - ): Path { - return if (autoSave) { - val now = LocalDateTime.now() - val savePath = autoSubFolderMode.pathFor(savePath!!, now) - val fileName = "%s-%s.fits".format(now.format(DATE_TIME_FORMAT), frameType) - Path.of("$savePath", fileName) - } else { - val fileName = "%s.fits".format(camera.name) - Path.of("$savePath", fileName) - } - } - } -} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureTask.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureTask.kt new file mode 100644 index 000000000..d7f210a64 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraExposureTask.kt @@ -0,0 +1,170 @@ +package nebulosa.api.cameras + +import nebulosa.api.tasks.AbstractTask +import nebulosa.common.concurrency.cancel.CancellationListener +import nebulosa.common.concurrency.cancel.CancellationSource +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.common.concurrency.latch.CountUpDownLatch +import nebulosa.indi.device.camera.* +import nebulosa.io.transferAndClose +import nebulosa.log.loggerFor +import okio.sink +import java.nio.file.Path +import java.time.Duration +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.io.path.createParentDirectories +import kotlin.io.path.outputStream + +data class CameraExposureTask( + @JvmField val camera: Camera, + @JvmField val request: CameraStartCaptureRequest, +) : AbstractTask(), CancellationListener { + + private val latch = CountUpDownLatch() + private val aborted = AtomicBoolean() + + @Volatile private var state = CameraExposureState.IDLE + @Volatile private var elapsedTime = Duration.ZERO + @Volatile private var remainingTime = Duration.ZERO + @Volatile private var progress = 0.0 + @Volatile private var savedPath: Path? = null + + val isAborted + get() = aborted.get() + + fun handleCameraEvent(event: CameraEvent) { + if (event.device === camera) { + when (event) { + is CameraFrameCaptured -> { + save(event) + } + is CameraExposureAborted, + is CameraExposureFailed, + is CameraDetached -> { + aborted.set(true) + latch.reset() + } + is CameraExposureProgressChanged -> { + val exposureTime = request.exposureTime + // minOf fix possible bug on SVBony exposure time? + remainingTime = minOf(event.device.exposureTime, request.exposureTime) + elapsedTime = exposureTime - remainingTime + progress = elapsedTime.toNanos().toDouble() / exposureTime.toNanos() + state = CameraExposureState.ELAPSED + sendEvent() + } + } + } + } + + override fun execute(cancellationToken: CancellationToken) { + if (camera.connected && !aborted.get()) { + LOG.info("Camera Exposure started. camera={}, request={}", camera, request) + + cancellationToken.waitForPause() + + latch.countUp() + + state = CameraExposureState.STARTED + sendEvent() + + with(camera) { + enableBlob() + + if (request.width > 0 && request.height > 0) { + frame(request.x, request.y, request.width, request.height) + } + + frameType(request.frameType) + frameFormat(request.frameFormat) + bin(request.binX, request.binY) + gain(request.gain) + offset(request.offset) + startCapture(request.exposureTime) + } + + try { + cancellationToken.listen(this) + latch.await() + } finally { + cancellationToken.unlisten(this) + } + + LOG.info("Camera Exposure finished. aborted={}, camera={}, request={}", aborted.get(), camera, request) + } else { + LOG.warn("camera not connected or aborted. aborted={}, camera={}, request={}", aborted.get(), camera, request) + } + } + + override fun onCancel(source: CancellationSource) { + camera.abortCapture() + } + + override fun reset() { + aborted.set(false) + latch.reset() + } + + override fun close() { + onCancel(CancellationSource.Close) + super.close() + } + + private fun save(event: CameraFrameCaptured) { + try { + val savedPath = request.makeSavePath(event.device) + + LOG.info("saving FITS image at {}", savedPath) + + savedPath.createParentDirectories() + + if (event.stream != null) { + event.stream!!.transferAndClose(savedPath.outputStream()) + } else if (event.image != null) { + savedPath.sink().use(event.image!!::write) + } else { + LOG.warn("invalid event. camera={}", event.device) + return + } + + this.savedPath = savedPath + state = CameraExposureState.FINISHED + + sendEvent() + } catch (e: Throwable) { + LOG.error("failed to save FITS image", e) + aborted.set(true) + } finally { + latch.countDown() + } + } + + private fun sendEvent() { + onNext(CameraExposureEvent(this, state, elapsedTime, remainingTime, progress, savedPath)) + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + @JvmStatic private val DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd.HHmmssSSS") + + @JvmStatic + internal fun CameraStartCaptureRequest.makeSavePath( + camera: Camera, autoSave: Boolean = this.autoSave, + ): Path { + require(savePath != null) { "savePath is required" } + + return if (autoSave) { + val now = LocalDateTime.now() + val savePath = autoSubFolderMode.pathFor(savePath, now) + val fileName = "%s-%s.fits".format(now.format(DATE_TIME_FORMAT), frameType) + Path.of("$savePath", fileName) + } else { + val fileName = "%s.fits".format(camera.name) + Path.of("$savePath", fileName) + } + } + } +} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraLoopExposureStep.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraLoopExposureStep.kt deleted file mode 100644 index 5129cff94..000000000 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraLoopExposureStep.kt +++ /dev/null @@ -1,49 +0,0 @@ -package nebulosa.api.cameras - -import nebulosa.batch.processing.JobExecution -import nebulosa.batch.processing.StepExecution -import nebulosa.batch.processing.StepResult -import nebulosa.batch.processing.delay.DelayStep -import nebulosa.indi.device.camera.Camera - -data class CameraLoopExposureStep( - override val camera: Camera, - override val request: CameraStartCaptureRequest, -) : CameraStartCaptureStep { - - private val cameraExposureStep = CameraExposureStep(camera, request) - private val delayStep = DelayStep(request.exposureDelay) - - init { - delayStep.registerDelayStepListener(cameraExposureStep) - } - - override fun registerCameraCaptureListener(listener: CameraCaptureListener): Boolean { - return cameraExposureStep.registerCameraCaptureListener(listener) - } - - override fun unregisterCameraCaptureListener(listener: CameraCaptureListener): Boolean { - return cameraExposureStep.unregisterCameraCaptureListener(listener) - } - - override fun execute(stepExecution: StepExecution): StepResult { - cameraExposureStep.execute(stepExecution) - delayStep.execute(stepExecution) - return StepResult.CONTINUABLE - } - - override fun stop(mayInterruptIfRunning: Boolean) { - cameraExposureStep.stop(mayInterruptIfRunning) - delayStep.stop(mayInterruptIfRunning) - } - - override fun beforeJob(jobExecution: JobExecution) { - cameraExposureStep.beforeJob(jobExecution) - delayStep.beforeJob(jobExecution) - } - - override fun afterJob(jobExecution: JobExecution) { - cameraExposureStep.afterJob(jobExecution) - delayStep.afterJob(jobExecution) - } -} diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt index b400834f7..074b2cdbb 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraService.kt @@ -34,17 +34,20 @@ class CameraService( } @Synchronized - fun startCapture(camera: Camera, request: CameraStartCaptureRequest): String { + fun startCapture(camera: Camera, request: CameraStartCaptureRequest) { val savePath = request.savePath ?.takeIf { "$it".isNotBlank() && it.exists() && it.isDirectory() } ?: Path.of("$capturesPath", camera.name, request.frameType.name) - return cameraCaptureExecutor - .execute(camera, request.copy(savePath = savePath)) + cameraCaptureExecutor.execute(camera, request.copy(savePath = savePath)) } @Synchronized fun abortCapture(camera: Camera) { cameraCaptureExecutor.stop(camera) } + + fun statusCapture(camera: Camera): CameraCaptureEvent? { + return cameraCaptureExecutor.status(camera) + } } diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt index 9482918b4..c39ab7323 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureRequest.kt @@ -1,10 +1,8 @@ package nebulosa.api.cameras -import com.fasterxml.jackson.databind.annotation.JsonDeserialize import jakarta.validation.Valid import jakarta.validation.constraints.Positive import jakarta.validation.constraints.PositiveOrZero -import nebulosa.api.beans.converters.time.DurationDeserializer import nebulosa.api.guiding.DitherAfterExposureRequest import nebulosa.indi.device.camera.FrameType import org.hibernate.validator.constraints.Range @@ -16,31 +14,32 @@ import java.time.Duration import java.time.temporal.ChronoUnit data class CameraStartCaptureRequest( - val enabled: Boolean = true, + @JvmField val enabled: Boolean = true, // Capture. - @field:DurationMin(nanos = 1000L) @field:DurationMax(minutes = 60L) val exposureTime: Duration = Duration.ZERO, - @field:Range(min = 0L, max = 1000L) val exposureAmount: Int = 1, // 0 = looping - @field:JsonDeserialize(using = DurationDeserializer::class) @field:DurationUnit(ChronoUnit.SECONDS) - @field:DurationMin(nanos = 0L) @field:DurationMax(seconds = 60L) val exposureDelay: Duration = Duration.ZERO, - @field:PositiveOrZero val x: Int = 0, - @field:PositiveOrZero val y: Int = 0, - @field:PositiveOrZero val width: Int = 0, - @field:PositiveOrZero val height: Int = 0, - val frameFormat: String? = null, - val frameType: FrameType = FrameType.LIGHT, - @field:Positive val binX: Int = 1, - @field:Positive val binY: Int = 1, - @field:PositiveOrZero val gain: Int = 0, - @field:PositiveOrZero val offset: Int = 0, - val autoSave: Boolean = false, - val savePath: Path? = null, - val autoSubFolderMode: AutoSubFolderMode = AutoSubFolderMode.OFF, - @field:Valid val dither: DitherAfterExposureRequest = DitherAfterExposureRequest.DISABLED, + @field:DurationMin(nanos = 1000L) @field:DurationMax(minutes = 60L) @JvmField val exposureTime: Duration = Duration.ZERO, + @field:Range(min = 0L, max = 1000L) @JvmField val exposureAmount: Int = 1, // 0 = looping + @field:DurationUnit(ChronoUnit.SECONDS) @field:DurationMin(nanos = 0L) @field:DurationMax(seconds = 60L) + @JvmField val exposureDelay: Duration = Duration.ZERO, + @field:PositiveOrZero @JvmField val x: Int = 0, + @field:PositiveOrZero @JvmField val y: Int = 0, + @field:PositiveOrZero @JvmField val width: Int = 0, + @field:PositiveOrZero @JvmField val height: Int = 0, + @JvmField val frameFormat: String? = null, + @JvmField val frameType: FrameType = FrameType.LIGHT, + @field:Positive @JvmField val binX: Int = 1, + @field:Positive @JvmField val binY: Int = 1, + @field:PositiveOrZero @JvmField val gain: Int = 0, + @field:PositiveOrZero @JvmField val offset: Int = 0, + @JvmField val autoSave: Boolean = false, + @JvmField val savePath: Path? = null, + @JvmField val autoSubFolderMode: AutoSubFolderMode = AutoSubFolderMode.OFF, + @field:Valid @JvmField val dither: DitherAfterExposureRequest = DitherAfterExposureRequest.DISABLED, + @JvmField val calibrationGroup: String? = null, // Filter Wheel. - val filterPosition: Int = 0, - val shutterPosition: Int = 0, + @JvmField val filterPosition: Int = 0, + @JvmField val shutterPosition: Int = 0, // Focuser. - val focusOffset: Int = 0, + @JvmField val focusOffset: Int = 0, ) { inline val isLoop diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureStep.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureStep.kt deleted file mode 100644 index 0e70c4241..000000000 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraStartCaptureStep.kt +++ /dev/null @@ -1,15 +0,0 @@ -package nebulosa.api.cameras - -import nebulosa.batch.processing.Step -import nebulosa.indi.device.camera.Camera - -sealed interface CameraStartCaptureStep : Step { - - val camera: Camera - - val request: CameraStartCaptureRequest - - fun registerCameraCaptureListener(listener: CameraCaptureListener): Boolean - - fun unregisterCameraCaptureListener(listener: CameraCaptureListener): Boolean -} diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt index 3fc34e50c..55d135d77 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionService.kt @@ -13,6 +13,7 @@ import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.gps.GPS import nebulosa.indi.device.guide.GuideOutput import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.rotator.Rotator import nebulosa.indi.device.thermometer.Thermometer import nebulosa.log.error import nebulosa.log.loggerFor @@ -106,31 +107,35 @@ class ConnectionService( disconnectAll() } - fun cameras(id: String): List { + fun cameras(id: String): Collection { return providers[id]?.cameras() ?: emptyList() } - fun mounts(id: String): List { + fun mounts(id: String): Collection { return providers[id]?.mounts() ?: emptyList() } - fun focusers(id: String): List { + fun focusers(id: String): Collection { return providers[id]?.focusers() ?: emptyList() } - fun wheels(id: String): List { + fun wheels(id: String): Collection { return providers[id]?.wheels() ?: emptyList() } - fun gpss(id: String): List { + fun rotators(id: String): Collection { + return providers[id]?.rotators() ?: emptyList() + } + + fun gpss(id: String): Collection { return providers[id]?.gps() ?: emptyList() } - fun guideOutputs(id: String): List { + fun guideOutputs(id: String): Collection { return providers[id]?.guideOutputs() ?: emptyList() } - fun thermometers(id: String): List { + fun thermometers(id: String): Collection { return providers[id]?.thermometers() ?: emptyList() } @@ -150,6 +155,10 @@ class ConnectionService( return providers.values.flatMap { it.wheels() } } + fun rotators(): List { + return providers.values.flatMap { it.rotators() } + } + fun gpss(): List { return providers.values.flatMap { it.gps() } } @@ -178,6 +187,10 @@ class ConnectionService( return providers[id]?.wheel(name) } + fun rotator(id: String, name: String): Rotator? { + return providers[id]?.rotator(name) + } + fun gps(id: String, name: String): GPS? { return providers[id]?.gps(name) } @@ -206,6 +219,10 @@ class ConnectionService( return providers.firstNotNullOfOrNull { it.value.wheel(name) } } + fun rotator(name: String): Rotator? { + return providers.firstNotNullOfOrNull { it.value.rotator(name) } + } + fun gps(name: String): GPS? { return providers.firstNotNullOfOrNull { it.value.gps(name) } } @@ -223,6 +240,7 @@ class ConnectionService( ?: mount(name) ?: focuser(name) ?: wheel(name) + ?: rotator(name) ?: guideOutput(name) ?: gps(name) ?: thermometer(name) diff --git a/api/src/main/kotlin/nebulosa/api/focusers/FocusOffsetStep.kt b/api/src/main/kotlin/nebulosa/api/focusers/FocusOffsetStep.kt deleted file mode 100644 index 78b44d61e..000000000 --- a/api/src/main/kotlin/nebulosa/api/focusers/FocusOffsetStep.kt +++ /dev/null @@ -1,68 +0,0 @@ -package nebulosa.api.focusers - -import nebulosa.api.wheels.WheelStep -import nebulosa.batch.processing.Step -import nebulosa.batch.processing.StepExecution -import nebulosa.batch.processing.StepResult -import nebulosa.common.concurrency.latch.CountUpDownLatch -import nebulosa.indi.device.focuser.Focuser -import nebulosa.indi.device.focuser.FocuserEvent -import nebulosa.indi.device.focuser.FocuserMoveFailed -import nebulosa.indi.device.focuser.FocuserPositionChanged -import nebulosa.log.loggerFor -import org.greenrobot.eventbus.EventBus -import org.greenrobot.eventbus.Subscribe -import org.greenrobot.eventbus.ThreadMode -import kotlin.math.abs - -class FocusOffsetStep( - val focuser: Focuser, - val offset: Int, -) : Step { - - private val latch = CountUpDownLatch() - private val expectedPosition = IntArray(2) - - @Subscribe(threadMode = ThreadMode.ASYNC) - fun onFocuserEvent(event: FocuserEvent) { - if (event is FocuserPositionChanged) { - if (focuser.position == expectedPosition[0] || focuser.position == expectedPosition[1]) { - latch.reset() - } - } else if (event is FocuserMoveFailed) { - LOG.warn("failed to move focuser. focuser={}, offset={}", focuser, offset) - latch.reset() - } - } - - override fun execute(stepExecution: StepExecution): StepResult { - if (focuser.connected && (focuser.canRelativeMove || focuser.canAbsoluteMove) && offset != 0) { - - EventBus.getDefault().register(this) - - latch.countUp() - - expectedPosition[0] = focuser.position + abs(offset) - expectedPosition[1] = focuser.position - abs(offset) - - if (focuser.canAbsoluteMove) focuser.moveFocusTo(focuser.position + offset) - else if (offset > 0) focuser.moveFocusOut(offset) - else focuser.moveFocusIn(-offset) - - latch.await() - - EventBus.getDefault().unregister(this) - } - - return StepResult.FINISHED - } - - override fun stop(mayInterruptIfRunning: Boolean) { - latch.reset() - } - - companion object { - - @JvmStatic private val LOG = loggerFor() - } -} diff --git a/api/src/main/kotlin/nebulosa/api/focusers/FocuserController.kt b/api/src/main/kotlin/nebulosa/api/focusers/FocuserController.kt index 2e344a369..382c9d1a4 100644 --- a/api/src/main/kotlin/nebulosa/api/focusers/FocuserController.kt +++ b/api/src/main/kotlin/nebulosa/api/focusers/FocuserController.kt @@ -2,7 +2,6 @@ package nebulosa.api.focusers import jakarta.validation.Valid import jakarta.validation.constraints.PositiveOrZero -import nebulosa.api.beans.converters.device.DeviceOrEntityParam import nebulosa.api.connection.ConnectionService import nebulosa.indi.device.focuser.Focuser import org.springframework.web.bind.annotation.* @@ -20,23 +19,23 @@ class FocuserController( } @GetMapping("{focuser}") - fun focuser(@DeviceOrEntityParam focuser: Focuser): Focuser { + fun focuser(focuser: Focuser): Focuser { return focuser } @PutMapping("{focuser}/connect") - fun connect(@DeviceOrEntityParam focuser: Focuser) { + fun connect(focuser: Focuser) { focuserService.connect(focuser) } @PutMapping("{focuser}/disconnect") - fun disconnect(@DeviceOrEntityParam focuser: Focuser) { + fun disconnect(focuser: Focuser) { focuserService.disconnect(focuser) } @PutMapping("{focuser}/move-in") fun moveIn( - @DeviceOrEntityParam focuser: Focuser, + focuser: Focuser, @RequestParam @Valid @PositiveOrZero steps: Int, ) { focuserService.moveIn(focuser, steps) @@ -44,7 +43,7 @@ class FocuserController( @PutMapping("{focuser}/move-out") fun moveOut( - @DeviceOrEntityParam focuser: Focuser, + focuser: Focuser, @RequestParam @Valid @PositiveOrZero steps: Int, ) { focuserService.moveOut(focuser, steps) @@ -52,20 +51,20 @@ class FocuserController( @PutMapping("{focuser}/move-to") fun moveTo( - @DeviceOrEntityParam focuser: Focuser, + focuser: Focuser, @RequestParam @Valid @PositiveOrZero steps: Int, ) { focuserService.moveTo(focuser, steps) } @PutMapping("{focuser}/abort") - fun abort(@DeviceOrEntityParam focuser: Focuser) { + fun abort(focuser: Focuser) { focuserService.abort(focuser) } @PutMapping("{focuser}/sync") fun sync( - @DeviceOrEntityParam focuser: Focuser, + focuser: Focuser, @RequestParam @Valid @PositiveOrZero steps: Int, ) { focuserService.sync(focuser, steps) diff --git a/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureEvent.kt b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureEvent.kt new file mode 100644 index 000000000..34fbd6056 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureEvent.kt @@ -0,0 +1,10 @@ +package nebulosa.api.guiding + +import java.time.Duration + +data class DitherAfterExposureEvent( + @JvmField val task: DitherAfterExposureTask, + @JvmField val state: DitherAfterExposureState = DitherAfterExposureState.IDLE, + @JvmField val dx: Double = 0.0, @JvmField val dy: Double = 0.0, + @JvmField val elapsedTime: Duration = Duration.ZERO, +) diff --git a/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureState.kt b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureState.kt new file mode 100644 index 000000000..8c2eebe21 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureState.kt @@ -0,0 +1,8 @@ +package nebulosa.api.guiding + +enum class DitherAfterExposureState { + IDLE, + STARTED, + DITHERED, + FINISHED, +} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureStep.kt b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureStep.kt deleted file mode 100644 index 62e7972bb..000000000 --- a/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureStep.kt +++ /dev/null @@ -1,47 +0,0 @@ -package nebulosa.api.guiding - -import nebulosa.batch.processing.Step -import nebulosa.batch.processing.StepExecution -import nebulosa.batch.processing.StepResult -import nebulosa.common.concurrency.latch.CountUpDownLatch -import nebulosa.guiding.GuideState -import nebulosa.guiding.Guider -import nebulosa.guiding.GuiderListener - -data class DitherAfterExposureStep( - @JvmField val request: DitherAfterExposureRequest, - @JvmField val guider: Guider, -) : Step, GuiderListener { - - private val ditherLatch = CountUpDownLatch() - @Volatile private var exposureCount = 0 - - override fun execute(stepExecution: StepExecution): StepResult { - if (guider.canDither && request.enabled && guider.state == GuideState.GUIDING) { - if (exposureCount < request.afterExposures) { - try { - guider.registerGuiderListener(this) - ditherLatch.countUp() - guider.dither(request.amount, request.raOnly) - ditherLatch.await() - } finally { - guider.unregisterGuiderListener(this) - } - } - - if (++exposureCount >= request.afterExposures) { - exposureCount = 0 - } - } - - return StepResult.FINISHED - } - - override fun stop(mayInterruptIfRunning: Boolean) { - ditherLatch.reset() - } - - override fun onDithered(dx: Double, dy: Double) { - ditherLatch.reset() - } -} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureTask.kt b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureTask.kt new file mode 100644 index 000000000..fb2de4a50 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/DitherAfterExposureTask.kt @@ -0,0 +1,87 @@ +package nebulosa.api.guiding + +import nebulosa.api.tasks.AbstractTask +import nebulosa.common.concurrency.cancel.CancellationListener +import nebulosa.common.concurrency.cancel.CancellationSource +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.common.concurrency.latch.CountUpDownLatch +import nebulosa.guiding.GuideState +import nebulosa.guiding.Guider +import nebulosa.guiding.GuiderListener +import nebulosa.log.loggerFor +import java.time.Duration +import kotlin.system.measureTimeMillis + +data class DitherAfterExposureTask( + @JvmField val guider: Guider?, + @JvmField val request: DitherAfterExposureRequest, +) : AbstractTask(), GuiderListener, CancellationListener { + + private val ditherLatch = CountUpDownLatch() + + @Volatile private var state = DitherAfterExposureState.IDLE + @Volatile private var dx = 0.0 + @Volatile private var dy = 0.0 + @Volatile private var elapsedTime = Duration.ZERO + + override fun execute(cancellationToken: CancellationToken) { + if (guider != null && guider.canDither && request.enabled + && guider.state == GuideState.GUIDING + && !cancellationToken.isDone + ) { + LOG.info("Dither started. request={}", request) + + try { + cancellationToken.listen(this) + guider.registerGuiderListener(this) + ditherLatch.countUp() + + state = DitherAfterExposureState.STARTED + sendEvent() + + elapsedTime = Duration.ofMillis(measureTimeMillis { + guider.dither(request.amount, request.raOnly) + ditherLatch.await() + }) + } finally { + state = DitherAfterExposureState.FINISHED + sendEvent() + + guider.unregisterGuiderListener(this) + cancellationToken.unlisten(this) + + LOG.info("Dither finished. request={}", request) + } + } + } + + override fun onDithered(dx: Double, dy: Double) { + this.dx = dx + this.dy = dy + state = DitherAfterExposureState.DITHERED + + LOG.info("dithered. dx={}, dy={}", dx, dy) + + ditherLatch.reset() + } + + override fun onCancel(source: CancellationSource) { + ditherLatch.onCancel(source) + } + + override fun reset() { + dx = 0.0 + dy = 0.0 + elapsedTime = Duration.ZERO + state = DitherAfterExposureState.IDLE + } + + private fun sendEvent() { + onNext(DitherAfterExposureEvent(this, state, dx, dy, elapsedTime)) + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuideOutputController.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuideOutputController.kt index e7f790faa..d7d0575d1 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/GuideOutputController.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuideOutputController.kt @@ -1,6 +1,5 @@ package nebulosa.api.guiding -import nebulosa.api.beans.converters.device.DeviceOrEntityParam import nebulosa.api.connection.ConnectionService import nebulosa.guiding.GuideDirection import nebulosa.indi.device.guide.GuideOutput @@ -22,23 +21,23 @@ class GuideOutputController( } @GetMapping("{guideOutput}") - fun guideOutput(@DeviceOrEntityParam guideOutput: GuideOutput): GuideOutput { + fun guideOutput(guideOutput: GuideOutput): GuideOutput { return guideOutput } @PutMapping("{guideOutput}/connect") - fun connect(@DeviceOrEntityParam guideOutput: GuideOutput) { + fun connect(guideOutput: GuideOutput) { guideOutputService.connect(guideOutput) } @PutMapping("{guideOutput}/disconnect") - fun disconnect(@DeviceOrEntityParam guideOutput: GuideOutput) { + fun disconnect(guideOutput: GuideOutput) { guideOutputService.disconnect(guideOutput) } @PutMapping("{guideOutput}/pulse") fun pulse( - @DeviceOrEntityParam guideOutput: GuideOutput, + guideOutput: GuideOutput, @RequestParam direction: GuideDirection, @RequestParam @DurationMin(nanos = 0L) @DurationMax(seconds = 60L) duration: Duration, ) { diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseEvent.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseEvent.kt new file mode 100644 index 000000000..c15be48dc --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseEvent.kt @@ -0,0 +1,9 @@ +package nebulosa.api.guiding + +import java.time.Duration + +data class GuidePulseEvent( + @JvmField val task: GuidePulseTask, + @JvmField val remainingTime: Duration = Duration.ZERO, + @JvmField val progress: Double = 0.0, +) diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseListener.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseListener.kt deleted file mode 100644 index 555969c0f..000000000 --- a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package nebulosa.api.guiding - -import nebulosa.batch.processing.StepExecution - -fun interface GuidePulseListener { - - fun onGuidePulseElapsed(step: GuidePulseStep, stepExecution: StepExecution) -} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseRequest.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseRequest.kt index 3b6d2134a..20406287f 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseRequest.kt @@ -4,6 +4,6 @@ import nebulosa.guiding.GuideDirection import java.time.Duration data class GuidePulseRequest( - val direction: GuideDirection, - val duration: Duration, + @JvmField val direction: GuideDirection, + @JvmField val duration: Duration, ) diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseStep.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseStep.kt deleted file mode 100644 index 56fcc0fc8..000000000 --- a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseStep.kt +++ /dev/null @@ -1,74 +0,0 @@ -package nebulosa.api.guiding - -import nebulosa.batch.processing.JobExecution -import nebulosa.batch.processing.Step -import nebulosa.batch.processing.StepExecution -import nebulosa.batch.processing.StepResult -import nebulosa.batch.processing.delay.DelayStep -import nebulosa.batch.processing.delay.DelayStepListener -import nebulosa.guiding.GuideDirection -import nebulosa.indi.device.guide.GuideOutput -import java.time.Duration - -data class GuidePulseStep( - @JvmField val guideOutput: GuideOutput, - @JvmField val request: GuidePulseRequest, -) : Step, DelayStepListener { - - private val listeners = LinkedHashSet() - private val delayStep = DelayStep(request.duration) - - init { - delayStep.registerDelayStepListener(this) - } - - fun registerGuidePulseListener(listener: GuidePulseListener) { - listeners.add(listener) - } - - fun unregisterGuidePulseListener(listener: GuidePulseListener) { - listeners.remove(listener) - } - - override fun execute(stepExecution: StepExecution): StepResult { - if (guideOutput.pulseGuide(request.duration, request.direction)) { - delayStep.execute(stepExecution) - } - - return StepResult.FINISHED - } - - override fun afterJob(jobExecution: JobExecution) { - guideOutput.stop() - } - - override fun stop(mayInterruptIfRunning: Boolean) { - guideOutput.stop() - delayStep.stop() - } - - override fun onDelayElapsed(step: DelayStep, stepExecution: StepExecution) { - listeners.forEach { it.onGuidePulseElapsed(this, stepExecution) } - } - - @Suppress("NOTHING_TO_INLINE") - private inline fun GuideOutput.stop() { - pulseGuide(Duration.ZERO, request.direction) - } - - companion object { - - @JvmStatic - private fun GuideOutput.pulseGuide(duration: Duration, direction: GuideDirection): Boolean { - when (direction) { - GuideDirection.NORTH -> guideNorth(duration) - GuideDirection.SOUTH -> guideSouth(duration) - GuideDirection.WEST -> guideWest(duration) - GuideDirection.EAST -> guideEast(duration) - else -> return false - } - - return true - } - } -} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseTask.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseTask.kt new file mode 100644 index 000000000..787757139 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuidePulseTask.kt @@ -0,0 +1,71 @@ +package nebulosa.api.guiding + +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.tasks.AbstractTask +import nebulosa.api.tasks.delay.DelayEvent +import nebulosa.api.tasks.delay.DelayTask +import nebulosa.common.concurrency.cancel.CancellationListener +import nebulosa.common.concurrency.cancel.CancellationSource +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.guiding.GuideDirection +import nebulosa.indi.device.guide.GuideOutput +import nebulosa.log.loggerFor +import java.time.Duration + +data class GuidePulseTask( + @JvmField val guideOutput: GuideOutput, + @JvmField val request: GuidePulseRequest, +) : AbstractTask(), CancellationListener, Consumer { + + private val delayTask = DelayTask(request.duration) + + init { + delayTask.subscribe(this) + } + + override fun execute(cancellationToken: CancellationToken) { + if (!cancellationToken.isDone && guideOutput.pulseGuide(request.duration, request.direction)) { + LOG.info("Guide Pulse started. guideOutput={}, duration={}, direction={}", guideOutput, request.duration.toMillis(), request.direction) + + try { + cancellationToken.listen(this) + delayTask.execute(cancellationToken) + } finally { + cancellationToken.unlisten(this) + } + + LOG.info("Guide Pulse finished. guideOutput={}, duration={}, direction={}", guideOutput, request.duration.toMillis(), request.direction) + } + } + + override fun onCancel(source: CancellationSource) { + guideOutput.pulseGuide(Duration.ZERO, request.direction) + } + + override fun accept(event: DelayEvent) { + onNext(GuidePulseEvent(this, event.remainingTime, event.progress)) + } + + override fun close() { + delayTask.close() + super.close() + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + + @JvmStatic + internal fun GuideOutput.pulseGuide(duration: Duration, direction: GuideDirection): Boolean { + when (direction) { + GuideDirection.NORTH -> guideNorth(duration) + GuideDirection.SOUTH -> guideSouth(duration) + GuideDirection.WEST -> guideWest(duration) + GuideDirection.EAST -> guideEast(duration) + else -> return false + } + + return true + } + } +} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleListener.kt b/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleListener.kt deleted file mode 100644 index fb289c0b1..000000000 --- a/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleListener.kt +++ /dev/null @@ -1,10 +0,0 @@ -package nebulosa.api.guiding - -import nebulosa.batch.processing.StepExecution - -interface WaitForSettleListener { - - fun onSettleStarted(step: WaitForSettleStep, stepExecution: StepExecution) - - fun onSettleFinished(step: WaitForSettleStep, stepExecution: StepExecution) -} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleStep.kt b/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleStep.kt deleted file mode 100644 index 5d5702554..000000000 --- a/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleStep.kt +++ /dev/null @@ -1,41 +0,0 @@ -package nebulosa.api.guiding - -import nebulosa.batch.processing.JobExecution -import nebulosa.batch.processing.Step -import nebulosa.batch.processing.StepExecution -import nebulosa.batch.processing.StepResult -import nebulosa.guiding.Guider - -data class WaitForSettleStep(@JvmField val guider: Guider) : Step { - - private val listeners = LinkedHashSet() - - fun registerWaitForSettleListener(listener: WaitForSettleListener) { - listeners.add(listener) - } - - fun unregisterWaitForSettleListener(listener: WaitForSettleListener) { - listeners.remove(listener) - } - - override fun execute(stepExecution: StepExecution): StepResult { - if (guider.isSettling && !stepExecution.jobExecution.cancellationToken.isDone) { - stepExecution.context[WAITING] = true - listeners.forEach { it.onSettleStarted(this, stepExecution) } - guider.waitForSettle(stepExecution.jobExecution.cancellationToken) - stepExecution.context[WAITING] = false - listeners.forEach { it.onSettleFinished(this, stepExecution) } - } - - return StepResult.FINISHED - } - - override fun afterJob(jobExecution: JobExecution) { - listeners.clear() - } - - companion object { - - const val WAITING = "WAIT_FOR_SETTLE.WAITING" - } -} diff --git a/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleTask.kt b/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleTask.kt new file mode 100644 index 000000000..1f4aa2435 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/guiding/WaitForSettleTask.kt @@ -0,0 +1,24 @@ +package nebulosa.api.guiding + +import nebulosa.api.tasks.Task +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.guiding.Guider +import nebulosa.log.loggerFor + +data class WaitForSettleTask( + @JvmField val guider: Guider?, +) : Task { + + override fun execute(cancellationToken: CancellationToken) { + if (guider != null && guider.isSettling && !cancellationToken.isDone) { + LOG.info("Wait For Settle started") + guider.waitForSettle(cancellationToken) + LOG.info("Wait For Settle finished") + } + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageAnnotation.kt b/api/src/main/kotlin/nebulosa/api/image/ImageAnnotation.kt index 145810a0f..970bfd4c9 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageAnnotation.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageAnnotation.kt @@ -1,23 +1,51 @@ package nebulosa.api.image +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import nebulosa.api.beans.converters.angle.DeclinationSerializer +import nebulosa.api.beans.converters.angle.RightAscensionSerializer import nebulosa.math.Angle import nebulosa.math.Point2D +import nebulosa.math.Velocity +import nebulosa.nova.astrometry.Constellation import nebulosa.skycatalog.DeepSkyObject import nebulosa.skycatalog.SkyObject +import nebulosa.skycatalog.SkyObjectType data class ImageAnnotation( override val x: Double, override val y: Double, - val star: DeepSkyObject? = null, - val dso: DeepSkyObject? = null, - val minorPlanet: SkyObject? = null, + val star: StarDSO? = null, + val dso: StarDSO? = null, + val minorPlanet: MinorPlanet? = null, ) : Point2D { - internal data class MinorPlanet( + data class StarDSO( + override val id: Long = 0L, + override val name: String, + override val type: SkyObjectType = SkyObjectType.STAR, + @field:JsonSerialize(using = RightAscensionSerializer::class) override val rightAscensionJ2000: Angle = 0.0, + @field:JsonSerialize(using = DeclinationSerializer::class) override val declinationJ2000: Angle = 0.0, + override val magnitude: Double = SkyObject.UNKNOWN_MAGNITUDE, + override val pmRA: Angle = 0.0, + override val pmDEC: Angle = 0.0, + override val parallax: Angle = 0.0, + override val radialVelocity: Velocity = 0.0, + override val redshift: Double = 0.0, + override val constellation: Constellation = Constellation.AND, + ) : DeepSkyObject { + + constructor(dso: DeepSkyObject) : this( + dso.id, dso.name, dso.type, dso.rightAscensionJ2000, dso.declinationJ2000, + dso.magnitude, dso.pmRA, dso.pmDEC, dso.parallax, dso.radialVelocity, dso.redshift, + dso.constellation + ) + } + + data class MinorPlanet( override val id: Long = 0L, override val name: String = "", - override val rightAscensionJ2000: Angle = 0.0, - override val declinationJ2000: Angle = 0.0, + @field:JsonSerialize(using = RightAscensionSerializer::class) override val rightAscensionJ2000: Angle = 0.0, + @field:JsonSerialize(using = DeclinationSerializer::class) override val declinationJ2000: Angle = 0.0, override val magnitude: Double = SkyObject.UNKNOWN_MAGNITUDE, ) : SkyObject } diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt b/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt index 967b709a9..a9119ede1 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageBucket.kt @@ -37,7 +37,7 @@ class ImageBucket { else -> throw IllegalArgumentException("invalid extension: $path") } - val image = representation.use { openedImage?.first?.load(it) ?: Image.open(it, debayer) } + val image = representation.use { Image.open(it, debayer) } put(path, image, solution) return image } diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageController.kt b/api/src/main/kotlin/nebulosa/api/image/ImageController.kt index 17ac495d2..63d9a58a6 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageController.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageController.kt @@ -3,10 +3,7 @@ package nebulosa.api.image import jakarta.servlet.http.HttpServletResponse import jakarta.validation.Valid import nebulosa.api.atlas.Location -import nebulosa.api.beans.converters.device.DeviceOrEntityParam import nebulosa.api.beans.converters.location.LocationParam -import nebulosa.image.algorithms.transformation.ProtectionMethod -import nebulosa.image.format.ImageChannel import nebulosa.indi.device.camera.Camera import nebulosa.star.detection.ImageStar import org.hibernate.validator.constraints.Range @@ -20,32 +17,13 @@ class ImageController( private val imageService: ImageService, ) { - @GetMapping + @PostMapping fun openImage( @RequestParam path: Path, - @DeviceOrEntityParam(required = false) camera: Camera?, - @RequestParam(required = false, defaultValue = "true") debayer: Boolean, - @RequestParam(required = false, defaultValue = "false") calibrate: Boolean, - @RequestParam(required = false, defaultValue = "false") force: Boolean, - @RequestParam(required = false, defaultValue = "false") autoStretch: Boolean, - @RequestParam(required = false, defaultValue = "0.0") shadow: Float, - @RequestParam(required = false, defaultValue = "1.0") highlight: Float, - @RequestParam(required = false, defaultValue = "0.5") midtone: Float, - @RequestParam(required = false, defaultValue = "false") mirrorHorizontal: Boolean, - @RequestParam(required = false, defaultValue = "false") mirrorVertical: Boolean, - @RequestParam(required = false, defaultValue = "false") invert: Boolean, - @RequestParam(required = false, defaultValue = "false") scnrEnabled: Boolean, - @RequestParam(required = false, defaultValue = "GREEN") scnrChannel: ImageChannel, - @RequestParam(required = false, defaultValue = "0.5") scnrAmount: Float, - @RequestParam(required = false, defaultValue = "AVERAGE_NEUTRAL") scnrProtectionMode: ProtectionMethod, + camera: Camera?, + @RequestBody transformation: ImageTransformation, output: HttpServletResponse, - ) = imageService.openImage( - path, camera, - debayer, calibrate, force, autoStretch, shadow, highlight, midtone, - mirrorHorizontal, mirrorVertical, invert, - scnrEnabled, scnrChannel, scnrAmount, scnrProtectionMode, - output, - ) + ) = imageService.openImage(path, camera, transformation, output) @DeleteMapping fun closeImage(@RequestParam path: Path) { @@ -53,8 +31,12 @@ class ImageController( } @PutMapping("save-as") - fun saveImageAs(@RequestParam inputPath: Path, @RequestParam outputPath: Path) { - imageService.saveImageAs(inputPath, outputPath) + fun saveImageAs( + @RequestParam path: Path, + camera: Camera?, + @RequestBody save: SaveImage + ) { + imageService.saveImageAs(path, save, camera) } @GetMapping("annotations") @@ -63,8 +45,9 @@ class ImageController( @RequestParam(required = false, defaultValue = "true") starsAndDSOs: Boolean, @RequestParam(required = false, defaultValue = "false") minorPlanets: Boolean, @RequestParam(required = false, defaultValue = "12.0") minorPlanetMagLimit: Double, + @RequestParam(required = false, defaultValue = "false") useSimbad: Boolean, @LocationParam location: Location? = null, - ) = imageService.annotations(path, starsAndDSOs, minorPlanets, minorPlanetMagLimit, location) + ) = imageService.annotations(path, starsAndDSOs, minorPlanets, minorPlanetMagLimit, useSimbad, location) @GetMapping("coordinate-interpolation") fun coordinateInterpolation(@RequestParam path: Path): CoordinateInterpolation? { diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageExtension.kt b/api/src/main/kotlin/nebulosa/api/image/ImageExtension.kt new file mode 100644 index 000000000..ba28ce766 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/image/ImageExtension.kt @@ -0,0 +1,8 @@ +package nebulosa.api.image + +enum class ImageExtension { + FITS, + XISF, + PNG, + JPG, +} diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt b/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt index 25d2d8807..f9afc085d 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.databind.annotation.JsonSerialize import nebulosa.api.beans.converters.angle.DeclinationSerializer import nebulosa.api.beans.converters.angle.RightAscensionSerializer +import nebulosa.fits.Bitpix import nebulosa.image.algorithms.computation.Statistics import nebulosa.indi.device.camera.Camera import java.nio.file.Path @@ -16,6 +17,7 @@ data class ImageInfo( @field:JsonSerialize(using = DeclinationSerializer::class) val declination: Double? = null, val solved: ImageSolved? = null, val headers: List = emptyList(), + val bitpix: Bitpix = Bitpix.BYTE, val camera: Camera? = null, @JsonIgnoreProperties("histogram") val statistics: Statistics.Data? = null, ) diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt index 216841c87..59d7ce7da 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt @@ -7,19 +7,21 @@ import nebulosa.api.atlas.SimbadEntityRepository import nebulosa.api.calibration.CalibrationFrameService import nebulosa.api.connection.ConnectionService import nebulosa.api.framing.FramingService +import nebulosa.api.image.ImageAnnotation.StarDSO import nebulosa.fits.* import nebulosa.image.Image import nebulosa.image.algorithms.computation.Histogram import nebulosa.image.algorithms.computation.Statistics import nebulosa.image.algorithms.transformation.* -import nebulosa.image.format.ImageChannel +import nebulosa.image.format.ImageModifier import nebulosa.indi.device.camera.Camera -import nebulosa.log.debug import nebulosa.log.loggerFor import nebulosa.math.* import nebulosa.nova.astrometry.VSOP87E import nebulosa.nova.position.Barycentric import nebulosa.sbd.SmallBodyDatabaseService +import nebulosa.simbad.SimbadSearch +import nebulosa.simbad.SimbadService import nebulosa.skycatalog.ClassificationType import nebulosa.skycatalog.SkyObjectType import nebulosa.star.detection.ImageStar @@ -40,7 +42,6 @@ import java.time.LocalDateTime import java.util.* import java.util.concurrent.CompletableFuture import javax.imageio.ImageIO -import kotlin.io.path.extension import kotlin.io.path.outputStream @Service @@ -50,12 +51,25 @@ class ImageService( private val calibrationFrameService: CalibrationFrameService, private val smallBodyDatabaseService: SmallBodyDatabaseService, private val simbadEntityRepository: SimbadEntityRepository, + private val simbadService: SimbadService, private val imageBucket: ImageBucket, private val threadPoolTaskExecutor: ThreadPoolTaskExecutor, private val connectionService: ConnectionService, private val starDetector: StarDetector, ) { + private enum class ImageOperation { + OPEN, + SAVE, + } + + private data class TransformedImage( + @JvmField val image: Image, + @JvmField val statistics: Statistics.Data? = null, + @JvmField val strectchParams: ScreenTransformFunction.Parameters? = null, + @JvmField val instrument: Camera? = null, + ) + val fovCameras: ByteArray by lazy { URI.create("https://github.com/tiagohm/nebulosa.data/raw/main/astrobin/cameras.json") .toURL().openConnection().getInputStream().readAllBytes() @@ -68,69 +82,82 @@ class ImageService( @Synchronized fun openImage( - path: Path, camera: Camera?, debayer: Boolean = true, calibrate: Boolean = false, force: Boolean = false, - autoStretch: Boolean = false, shadow: Float = 0f, highlight: Float = 1f, midtone: Float = 0.5f, - mirrorHorizontal: Boolean = false, mirrorVertical: Boolean = false, invert: Boolean = false, - scnrEnabled: Boolean = false, scnrChannel: ImageChannel = ImageChannel.GREEN, scnrAmount: Float = 0.5f, - scnrProtectionMode: ProtectionMethod = ProtectionMethod.AVERAGE_NEUTRAL, + path: Path, camera: Camera?, transformation: ImageTransformation, output: HttpServletResponse, ) { - val image = imageBucket.open(path, debayer, force = force) + val image = imageBucket.open(path, transformation.debayer, force = transformation.force) + val (transformedImage, statistics, stretchParams, instrument) = image.transform(true, transformation, ImageOperation.OPEN, camera) + val info = ImageInfo( + path, + transformedImage.width, transformedImage.height, transformedImage.mono, + stretchParams!!.shadow, stretchParams.highlight, stretchParams.midtone, + transformedImage.header.rightAscension.takeIf { it.isFinite() }, + transformedImage.header.declination.takeIf { it.isFinite() }, + imageBucket[path]?.second?.let(::ImageSolved), + transformedImage.header.mapNotNull { if (it.isCommentStyle) null else ImageHeaderItem(it.key, it.value) }, + transformedImage.header.bitpix, instrument, statistics, + ) + + output.addHeader(IMAGE_INFO_HEADER, objectMapper.writeValueAsString(info)) + output.contentType = "image/png" + + ImageIO.write(transformedImage, "PNG", output.outputStream) + } + + private fun Image.transform( + enabled: Boolean, transformation: ImageTransformation, + operation: ImageOperation, camera: Camera? = null + ): TransformedImage { + val instrument = camera ?: header.instrument?.let(connectionService::camera) + + val (autoStretch, shadow, highlight, midtone) = transformation.stretch + val scnrEnabled = transformation.scnr.channel != null val manualStretch = shadow != 0f || highlight != 1f || midtone != 0.5f - var stretchParams = ScreenTransformFunction.Parameters(midtone, shadow, highlight) - val shouldBeTransformed = autoStretch || manualStretch - || mirrorHorizontal || mirrorVertical || invert - || scnrEnabled + val shouldBeTransformed = enabled && (autoStretch || manualStretch + || transformation.mirrorHorizontal || transformation.mirrorVertical || transformation.invert + || scnrEnabled) - var transformedImage = if (shouldBeTransformed) image.clone() else image - val instrument = camera?.name ?: image.header.instrument + var transformedImage = if (shouldBeTransformed) clone() else this - if (calibrate && !instrument.isNullOrBlank()) { - transformedImage = calibrationFrameService.calibrate(instrument, transformedImage, transformedImage === image) + if (enabled && !transformation.calibrationGroup.isNullOrBlank()) { + transformedImage = calibrationFrameService.calibrate(transformation.calibrationGroup, transformedImage, transformedImage === this) } - if (mirrorHorizontal) { + if (enabled && transformation.mirrorHorizontal) { transformedImage = HorizontalFlip.transform(transformedImage) } - if (mirrorVertical) { + if (enabled && transformation.mirrorVertical) { transformedImage = VerticalFlip.transform(transformedImage) } - if (scnrEnabled) { - transformedImage = SubtractiveChromaticNoiseReduction(scnrChannel, scnrAmount, scnrProtectionMode).transform(transformedImage) + if (enabled && scnrEnabled) { + val (channel, amount, method) = transformation.scnr + transformedImage = SubtractiveChromaticNoiseReduction(channel!!, amount, method) + .transform(transformedImage) } - val statistics = transformedImage.compute(Statistics.GRAY) + val statistics = if (operation == ImageOperation.OPEN) transformedImage.compute(Statistics.GRAY) + else null + + var stretchParams = ScreenTransformFunction.Parameters.DEFAULT - if (autoStretch) { - stretchParams = AutoScreenTransformFunction.compute(transformedImage) - transformedImage = ScreenTransformFunction(stretchParams).transform(transformedImage) - } else if (manualStretch) { - transformedImage = ScreenTransformFunction(stretchParams).transform(transformedImage) + if (enabled) { + if (autoStretch) { + stretchParams = AutoScreenTransformFunction.compute(transformedImage) + transformedImage = ScreenTransformFunction(stretchParams).transform(transformedImage) + } else if (manualStretch) { + stretchParams = ScreenTransformFunction.Parameters(midtone, shadow, highlight) + transformedImage = ScreenTransformFunction(stretchParams).transform(transformedImage) + } } - if (invert) { + if (enabled && transformation.invert) { transformedImage = Invert.transform(transformedImage) } - val info = ImageInfo( - path, - transformedImage.width, transformedImage.height, transformedImage.mono, - stretchParams.shadow, stretchParams.highlight, stretchParams.midtone, - transformedImage.header.rightAscension.takeIf { it.isFinite() }, - transformedImage.header.declination.takeIf { it.isFinite() }, - imageBucket[path]?.second?.let(::ImageSolved), - transformedImage.header.mapNotNull { if (it.isCommentStyle) null else ImageHeaderItem(it.key, it.value) }, - instrument?.let(connectionService::camera), - statistics, - ) - - output.addHeader("X-Image-Info", objectMapper.writeValueAsString(info)) - output.contentType = "image/png" - - ImageIO.write(transformedImage, "PNG", output.outputStream) + return TransformedImage(transformedImage, statistics, stretchParams, instrument) } @Synchronized @@ -143,7 +170,7 @@ class ImageService( fun annotations( path: Path, starsAndDSOs: Boolean, minorPlanets: Boolean, - minorPlanetMagLimit: Double = 12.0, + minorPlanetMagLimit: Double = 12.0, useSimbad: Boolean = false, location: Location? = null, ): List { val (image, calibration) = imageBucket[path] ?: return emptyList() @@ -197,42 +224,41 @@ class ImageService( } } - LOG.info("Found {} minor planets", count) + LOG.info("found {} minor planets", count) }.whenComplete { _, e -> e?.printStackTrace() } .also(tasks::add) } if (starsAndDSOs) { threadPoolTaskExecutor.submitCompletable { - val barycentric = VSOP87E.EARTH.at(UTC(TimeYMDHMS(dateTime))) + LOG.info("finding star/DSO annotations. dateTime={}, useSimbad={}, calibration={}", dateTime, useSimbad, calibration) - LOG.info("finding star/DSO annotations. dateTime={}, calibration={}", dateTime, calibration) + val rightAscension = calibration.rightAscension + val declination = calibration.declination + val radius = calibration.radius - val catalog = simbadEntityRepository.find(null, null, calibration.rightAscension, calibration.declination, calibration.radius) + val catalog = if (useSimbad) { + simbadService.search(SimbadSearch.Builder().region(rightAscension, declination, radius).build()) + } else { + simbadEntityRepository.find(null, null, rightAscension, declination, radius) + } var count = 0 + val barycentric = VSOP87E.EARTH.at(UTC(TimeYMDHMS(dateTime))) for (entry in catalog) { if (entry.type == SkyObjectType.EXTRA_SOLAR_PLANET) continue val astrometric = barycentric.observe(entry).equatorial() - LOG.debug { - "%s: %s %s -> %s %s".format( - entry.name, - entry.rightAscensionJ2000.formatHMS(), entry.declinationJ2000.formatSignedDMS(), - astrometric.longitude.normalized.formatHMS(), astrometric.latitude.formatSignedDMS(), - ) - } - val (x, y) = wcs.skyToPix(astrometric.longitude.normalized, astrometric.latitude) - val annotation = if (entry.type.classification == ClassificationType.STAR) ImageAnnotation(x, y, star = entry) - else ImageAnnotation(x, y, dso = entry) + val annotation = if (entry.type.classification == ClassificationType.STAR) ImageAnnotation(x, y, star = StarDSO(entry)) + else ImageAnnotation(x, y, dso = StarDSO(entry)) annotations.add(annotation) count++ } - LOG.info("Found {} stars/DSOs", count) + LOG.info("found {} stars/DSOs", count) }.whenComplete { _, e -> e?.printStackTrace() } .also(tasks::add) } @@ -244,18 +270,20 @@ class ImageService( return annotations } - fun saveImageAs(inputPath: Path, outputPath: Path) { - if (inputPath != outputPath) { - val image = imageBucket[inputPath]?.first - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found") - - when (outputPath.extension.uppercase()) { - "PNG" -> outputPath.outputStream().use { ImageIO.write(image, "PNG", it) } - "JPG", "JPEG" -> outputPath.outputStream().use { ImageIO.write(image, "JPEG", it) } - "FIT", "FITS" -> outputPath.sink().use { image.writeTo(it, FitsFormat) } - "XISF" -> outputPath.sink().use { image.writeTo(it, XisfFormat) } - else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid format") - } + fun saveImageAs(inputPath: Path, save: SaveImage, camera: Camera?) { + val (image) = imageBucket[inputPath]?.first?.transform(save.shouldBeTransformed, save.transformation, ImageOperation.SAVE) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found") + + require(save.path != null) + + val modifier = ImageModifier + .bitpix(save.bitpix) + + when (save.format) { + ImageExtension.FITS -> save.path.sink().use { image.writeTo(it, FitsFormat, modifier) } + ImageExtension.XISF -> save.path.sink().use { image.writeTo(it, XisfFormat, modifier) } + ImageExtension.PNG -> save.path.outputStream().use { ImageIO.write(image, "PNG", it) } + ImageExtension.JPG -> save.path.outputStream().use { ImageIO.write(image, "JPEG", it) } } } @@ -320,6 +348,7 @@ class ImageService( @JvmStatic private val LOG = loggerFor() + private const val IMAGE_INFO_HEADER = "X-Image-Info" private const val COORDINATE_INTERPOLATION_DELTA = 24 } } diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt b/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt index d9ab40126..aaf2a8122 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt @@ -4,6 +4,7 @@ import nebulosa.math.* import nebulosa.plate.solving.PlateSolution data class ImageSolved( + val solved: Boolean = false, val orientation: Double = 0.0, val scale: Double = 0.0, val rightAscensionJ2000: String = "", @@ -14,6 +15,7 @@ data class ImageSolved( ) { constructor(solution: PlateSolution) : this( + solution.solved, solution.orientation.toDegrees, solution.scale.toArcsec, solution.rightAscension.formatHMS(), diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageTransformation.kt b/api/src/main/kotlin/nebulosa/api/image/ImageTransformation.kt new file mode 100644 index 000000000..96d9b80c5 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/image/ImageTransformation.kt @@ -0,0 +1,46 @@ +package nebulosa.api.image + +import nebulosa.image.algorithms.transformation.ProtectionMethod +import nebulosa.image.format.ImageChannel + +data class ImageTransformation( + val force: Boolean = false, + val calibrationGroup: String? = null, + val debayer: Boolean = true, + val stretch: Stretch = Stretch.EMPTY, + val mirrorHorizontal: Boolean = false, + val mirrorVertical: Boolean = false, + val invert: Boolean = false, + val scnr: SCNR = SCNR.EMPTY, +) { + + data class SCNR( + val channel: ImageChannel? = ImageChannel.GREEN, + val amount: Float = 0.5f, + val method: ProtectionMethod = ProtectionMethod.AVERAGE_NEUTRAL, + ) { + + companion object { + + @JvmStatic val EMPTY = SCNR() + } + } + + data class Stretch( + val auto: Boolean = false, + val shadow: Float = 0f, + val highlight: Float = 0.5f, + val midtone: Float = 1f, + ) { + + companion object { + + @JvmStatic val EMPTY = Stretch() + } + } + + companion object { + + @JvmStatic val EMPTY = ImageTransformation() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/image/SaveImage.kt b/api/src/main/kotlin/nebulosa/api/image/SaveImage.kt new file mode 100644 index 000000000..1bfa717f4 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/image/SaveImage.kt @@ -0,0 +1,12 @@ +package nebulosa.api.image + +import nebulosa.fits.Bitpix +import java.nio.file.Path + +data class SaveImage( + val format: ImageExtension = ImageExtension.FITS, + val bitpix: Bitpix = Bitpix.BYTE, + val shouldBeTransformed: Boolean = true, + val transformation: ImageTransformation = ImageTransformation.EMPTY, + val path: Path? = null, +) diff --git a/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt b/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt index ea9f2621b..dae71b651 100644 --- a/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt +++ b/api/src/main/kotlin/nebulosa/api/indi/INDIController.kt @@ -1,7 +1,6 @@ package nebulosa.api.indi import jakarta.validation.Valid -import nebulosa.api.beans.converters.device.DeviceOrEntityParam import nebulosa.indi.device.Device import nebulosa.indi.device.PropertyVector import org.springframework.web.bind.annotation.* @@ -13,20 +12,20 @@ class INDIController( ) { @GetMapping("{device}/properties") - fun properties(@DeviceOrEntityParam device: Device): Collection> { + fun properties(device: Device): Collection> { return indiService.properties(device) } @PutMapping("{device}/send") fun sendProperty( - @DeviceOrEntityParam device: Device, + device: Device, @RequestBody @Valid body: INDISendProperty, ) { return indiService.sendProperty(device, body) } @GetMapping("{device}/log") - fun log(@DeviceOrEntityParam device: Device): List { + fun log(device: Device): List { return synchronized(device.messages) { device.messages } } diff --git a/api/src/main/kotlin/nebulosa/api/mounts/CelestialLocationType.kt b/api/src/main/kotlin/nebulosa/api/mounts/CelestialLocationType.kt index 382485c3b..246c95010 100644 --- a/api/src/main/kotlin/nebulosa/api/mounts/CelestialLocationType.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/CelestialLocationType.kt @@ -7,4 +7,5 @@ enum class CelestialLocationType { GALACTIC_CENTER, MERIDIAN_EQUATOR, MERIDIAN_ECLIPTIC, + EQUATOR_ECLIPTIC, } diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountController.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountController.kt index c3d8d1d67..ee8103c64 100644 --- a/api/src/main/kotlin/nebulosa/api/mounts/MountController.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountController.kt @@ -2,8 +2,8 @@ package nebulosa.api.mounts import jakarta.validation.Valid import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Positive import jakarta.validation.constraints.PositiveOrZero -import nebulosa.api.beans.converters.device.DeviceOrEntityParam import nebulosa.api.beans.converters.time.DateAndTimeParam import nebulosa.api.connection.ConnectionService import nebulosa.guiding.GuideDirection @@ -32,23 +32,23 @@ class MountController( } @GetMapping("{mount}") - fun mount(@DeviceOrEntityParam mount: Mount): Mount { + fun mount(mount: Mount): Mount { return mount } @PutMapping("{mount}/connect") - fun connect(@DeviceOrEntityParam mount: Mount) { + fun connect(mount: Mount) { mountService.connect(mount) } @PutMapping("{mount}/disconnect") - fun disconnect(@DeviceOrEntityParam mount: Mount) { + fun disconnect(mount: Mount) { mountService.disconnect(mount) } @PutMapping("{mount}/tracking") fun tracking( - @DeviceOrEntityParam mount: Mount, + mount: Mount, @RequestParam enabled: Boolean, ) { mountService.tracking(mount, enabled) @@ -56,7 +56,7 @@ class MountController( @PutMapping("{mount}/sync") fun sync( - @DeviceOrEntityParam mount: Mount, + mount: Mount, @RequestParam @Valid @NotBlank rightAscension: String, @RequestParam @Valid @NotBlank declination: String, @RequestParam(required = false, defaultValue = "false") j2000: Boolean, @@ -66,7 +66,7 @@ class MountController( @PutMapping("{mount}/slew") fun slew( - @DeviceOrEntityParam mount: Mount, + mount: Mount, @RequestParam @Valid @NotBlank rightAscension: String, @RequestParam @Valid @NotBlank declination: String, @RequestParam(required = false, defaultValue = "false") j2000: Boolean, @@ -76,7 +76,7 @@ class MountController( @PutMapping("{mount}/goto") fun goTo( - @DeviceOrEntityParam mount: Mount, + mount: Mount, @RequestParam @Valid @NotBlank rightAscension: String, @RequestParam @Valid @NotBlank declination: String, @RequestParam(required = false, defaultValue = "false") j2000: Boolean, @@ -85,18 +85,18 @@ class MountController( } @PutMapping("{mount}/home") - fun home(@DeviceOrEntityParam mount: Mount) { + fun home(mount: Mount) { mountService.home(mount) } @PutMapping("{mount}/abort") - fun abort(@DeviceOrEntityParam mount: Mount) { + fun abort(mount: Mount) { mountService.abort(mount) } @PutMapping("{mount}/track-mode") fun trackMode( - @DeviceOrEntityParam mount: Mount, + mount: Mount, @RequestParam mode: TrackMode, ) { mountService.trackMode(mount, mode) @@ -104,7 +104,7 @@ class MountController( @PutMapping("{mount}/slew-rate") fun slewRate( - @DeviceOrEntityParam mount: Mount, + mount: Mount, @RequestParam @Valid @NotBlank rate: String, ) { mountService.slewRate(mount, mount.slewRates.first { it.name == rate }) @@ -112,7 +112,7 @@ class MountController( @PutMapping("{mount}/move") fun move( - @DeviceOrEntityParam mount: Mount, + mount: Mount, @RequestParam direction: GuideDirection, @RequestParam enabled: Boolean, ) { @@ -120,18 +120,18 @@ class MountController( } @PutMapping("{mount}/park") - fun park(@DeviceOrEntityParam mount: Mount) { + fun park(mount: Mount) { mountService.park(mount) } @PutMapping("{mount}/unpark") - fun unpark(@DeviceOrEntityParam mount: Mount) { + fun unpark(mount: Mount) { mountService.unpark(mount) } @PutMapping("{mount}/coordinates") fun coordinates( - @DeviceOrEntityParam mount: Mount, + mount: Mount, @RequestParam @Valid @NotBlank longitude: String, @RequestParam @Valid @NotBlank latitude: String, @RequestParam(required = false, defaultValue = "0.0") elevation: Double, @@ -141,7 +141,7 @@ class MountController( @PutMapping("{mount}/datetime") fun dateTime( - @DeviceOrEntityParam mount: Mount, + mount: Mount, @DateAndTimeParam dateTime: LocalDateTime, @RequestParam @Valid @Range(min = -720, max = 720) offsetInMinutes: Int, ) { @@ -149,7 +149,7 @@ class MountController( } @GetMapping("{mount}/location/{type}") - fun celestialLocation(@DeviceOrEntityParam mount: Mount, @PathVariable type: CelestialLocationType): ComputedLocation { + fun celestialLocation(mount: Mount, @PathVariable type: CelestialLocationType): ComputedLocation { return when (type) { CelestialLocationType.ZENITH -> mountService.computeZenithLocation(mount) CelestialLocationType.NORTH_POLE -> mountService.computeNorthCelestialPoleLocation(mount) @@ -157,12 +157,13 @@ class MountController( CelestialLocationType.GALACTIC_CENTER -> mountService.computeGalacticCenterLocation(mount) CelestialLocationType.MERIDIAN_EQUATOR -> mountService.computeMeridianEquatorLocation(mount) CelestialLocationType.MERIDIAN_ECLIPTIC -> mountService.computeMeridianEclipticLocation(mount) + CelestialLocationType.EQUATOR_ECLIPTIC -> mountService.computeEquatorEclipticLocation(mount) } } @GetMapping("{mount}/location") fun location( - @DeviceOrEntityParam mount: Mount, + mount: Mount, @RequestParam rightAscension: String, @RequestParam declination: String, @RequestParam(required = false, defaultValue = "false") j2000: Boolean, @RequestParam(required = false, defaultValue = "true") equatorial: Boolean, @@ -177,11 +178,31 @@ class MountController( @PutMapping("{mount}/point-here") fun pointMountHere( - @DeviceOrEntityParam mount: Mount, + mount: Mount, @RequestParam path: Path, @RequestParam @Valid @PositiveOrZero x: Double, @RequestParam @Valid @PositiveOrZero y: Double, ) { mountService.pointMountHere(mount, path, x, y) } + + @PutMapping("{mount}/remote-control/start") + fun remoteControlStart( + mount: Mount, + @RequestParam type: MountRemoteControlType, + @RequestParam(required = false, defaultValue = "0.0.0.0") host: String, + @RequestParam(required = false, defaultValue = "10001") @Valid @Positive port: Int, + ) { + mountService.remoteControlStart(mount, type, host, port) + } + + @PutMapping("{mount}/remote-control/stop") + fun remoteControlStart(mount: Mount, @RequestParam type: MountRemoteControlType) { + mountService.remoteControlStop(mount, type) + } + + @GetMapping("{mount}/remote-control") + fun remoteControlList(mount: Mount): List { + return mountService.remoteControlList(mount) + } } diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountMoveEvent.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountMoveEvent.kt new file mode 100644 index 000000000..5e627cd97 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountMoveEvent.kt @@ -0,0 +1,9 @@ +package nebulosa.api.mounts + +import java.time.Duration + +data class MountMoveEvent( + @JvmField val task: MountMoveTask, + @JvmField val remainingTime: Duration = Duration.ZERO, + @JvmField val progress: Double = 0.0, +) diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountMoveRequest.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountMoveRequest.kt new file mode 100644 index 000000000..ecb288399 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountMoveRequest.kt @@ -0,0 +1,10 @@ +package nebulosa.api.mounts + +import nebulosa.guiding.GuideDirection +import java.time.Duration + +data class MountMoveRequest( + @JvmField val direction: GuideDirection, + @JvmField val duration: Duration, + @JvmField val speed: String? = null, +) diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountMoveTask.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountMoveTask.kt new file mode 100644 index 000000000..9279f7400 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountMoveTask.kt @@ -0,0 +1,78 @@ +package nebulosa.api.mounts + +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.tasks.AbstractTask +import nebulosa.api.tasks.delay.DelayEvent +import nebulosa.api.tasks.delay.DelayTask +import nebulosa.common.concurrency.cancel.CancellationListener +import nebulosa.common.concurrency.cancel.CancellationSource +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.guiding.GuideDirection +import nebulosa.indi.device.mount.Mount +import nebulosa.log.loggerFor + +data class MountMoveTask( + @JvmField val mount: Mount, + @JvmField val request: MountMoveRequest, +) : AbstractTask(), CancellationListener, Consumer { + + private val delayTask = DelayTask(request.duration) + + init { + delayTask.subscribe(this) + } + + override fun execute(cancellationToken: CancellationToken) { + if (!cancellationToken.isDone && request.duration.toMillis() > 0) { + mount.slewRates.takeIf { !request.speed.isNullOrBlank() } + ?.find { it.name == request.speed } + ?.also { mount.slewRate(it) } + + mount.move(request.direction, true) + + LOG.info("Mount Move started. mount={}, request={}", mount, request) + + try { + cancellationToken.listen(this) + delayTask.execute(cancellationToken) + } finally { + stop() + cancellationToken.unlisten(this) + } + + LOG.info("Mount Move finished. mount={}, request={}", mount, request) + } + } + + override fun onCancel(source: CancellationSource) { + stop() + } + + fun stop() { + mount.move(request.direction, false) + } + + override fun accept(event: DelayEvent) { + onNext(MountMoveEvent(this, event.remainingTime, event.progress)) + } + + override fun close() { + delayTask.close() + super.close() + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + + @JvmStatic + private fun Mount.move(direction: GuideDirection, enabled: Boolean) { + when (direction) { + GuideDirection.NORTH -> moveNorth(enabled) + GuideDirection.SOUTH -> moveSouth(enabled) + GuideDirection.WEST -> moveWest(enabled) + GuideDirection.EAST -> moveEast(enabled) + } + } + } +} diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountRemoteControl.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountRemoteControl.kt new file mode 100644 index 000000000..15118bcb1 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountRemoteControl.kt @@ -0,0 +1,123 @@ +package nebulosa.api.mounts + +import com.fasterxml.jackson.annotation.JsonIgnore +import nebulosa.indi.device.DeviceEvent +import nebulosa.indi.device.DeviceEventHandler +import nebulosa.indi.device.INDIDeviceProvider +import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.mount.MountEquatorialCoordinatesChanged +import nebulosa.lx200.protocol.LX200MountHandler +import nebulosa.lx200.protocol.LX200MountHandlerAdapter +import nebulosa.lx200.protocol.LX200ProtocolServer +import nebulosa.math.Angle +import nebulosa.netty.NettyServer +import nebulosa.stellarium.protocol.StellariumMountHandler +import nebulosa.stellarium.protocol.StellariumMountHandlerAdapter +import nebulosa.stellarium.protocol.StellariumProtocolServer +import java.io.Closeable +import java.time.OffsetDateTime + +data class MountRemoteControl( + @JvmField val type: MountRemoteControlType, + @field:JsonIgnore @JvmField val server: NettyServer, + @JvmField val mount: Mount, +) : StellariumMountHandler, LX200MountHandler, DeviceEventHandler, Closeable { + + @JsonIgnore private val stellariumAdapter = StellariumMountHandlerAdapter(mount) + @JsonIgnore private val lx200Adapter = LX200MountHandlerAdapter(mount) + @JsonIgnore private val deviceProvider = mount.sender as? INDIDeviceProvider + + init { + if (server is StellariumProtocolServer) { + deviceProvider?.registerDeviceEventHandler(this) + server.attachMountHandler(this) + } else if (server is LX200ProtocolServer) { + server.attachMountHandler(this) + } + } + + val host + get() = server.host + + val port + get() = server.port + + val running + get() = server.running + + override val rightAscension + get() = if (server is StellariumProtocolServer) stellariumAdapter.rightAscension + else lx200Adapter.rightAscension + + override val declination + get() = if (server is StellariumProtocolServer) stellariumAdapter.declination + else lx200Adapter.declination + + override val latitude + get() = lx200Adapter.latitude + + override val longitude + get() = lx200Adapter.longitude + + override val slewing + get() = lx200Adapter.slewing + + override val tracking + get() = lx200Adapter.tracking + + override val parked + get() = lx200Adapter.parked + + override fun goTo(rightAscension: Angle, declination: Angle) { + if (server is StellariumProtocolServer) stellariumAdapter.goTo(rightAscension, declination) + else lx200Adapter.goTo(rightAscension, declination) + } + + override fun syncTo(rightAscension: Angle, declination: Angle) { + lx200Adapter.syncTo(rightAscension, declination) + } + + override fun abort() { + lx200Adapter.abort() + } + + override fun moveNorth(enabled: Boolean) { + lx200Adapter.moveNorth(enabled) + } + + override fun moveSouth(enabled: Boolean) { + lx200Adapter.moveSouth(enabled) + } + + override fun moveWest(enabled: Boolean) { + lx200Adapter.moveWest(enabled) + } + + override fun moveEast(enabled: Boolean) { + lx200Adapter.moveEast(enabled) + } + + override fun time(time: OffsetDateTime) { + lx200Adapter.time(time) + } + + override fun coordinates(longitude: Angle, latitude: Angle) { + lx200Adapter.coordinates(longitude, latitude) + } + + override fun onEventReceived(event: DeviceEvent<*>) { + if (event is MountEquatorialCoordinatesChanged) { + (server as StellariumProtocolServer).sendCurrentPosition(mount.rightAscension, mount.declination) + } + } + + override fun onConnectionClosed() { + close() + } + + override fun close() { + lx200Adapter.abort() + deviceProvider?.unregisterDeviceEventHandler(this) + server.close() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountRemoteControlType.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountRemoteControlType.kt new file mode 100644 index 000000000..9641863eb --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountRemoteControlType.kt @@ -0,0 +1,6 @@ +package nebulosa.api.mounts + +enum class MountRemoteControlType { + STELLARIUM, + LX200, +} diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt index 9843904c0..3ac78f22b 100644 --- a/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountService.kt @@ -9,12 +9,14 @@ import nebulosa.erfa.SphericalCoordinate import nebulosa.guiding.GuideDirection import nebulosa.indi.device.mount.* import nebulosa.log.loggerFor +import nebulosa.lx200.protocol.LX200ProtocolServer import nebulosa.math.* import nebulosa.nova.astrometry.Constellation import nebulosa.nova.frame.Ecliptic import nebulosa.nova.position.GeographicPosition import nebulosa.nova.position.Geoid import nebulosa.nova.position.ICRF +import nebulosa.stellarium.protocol.StellariumProtocolServer import nebulosa.time.CurrentTime import nebulosa.wcs.WCS import org.greenrobot.eventbus.Subscribe @@ -29,6 +31,7 @@ import java.time.OffsetDateTime class MountService(private val imageBucket: ImageBucket) { private val site = HashMap(2) + private val remoteControls = ArrayList(2) @Subscribe(threadMode = ThreadMode.ASYNC) fun onMountGeographicCoordinateChanged(event: MountGeographicCoordinateChanged) { @@ -155,9 +158,15 @@ class MountService(private val imageBucket: ImageBucket) { } fun computeMeridianEclipticLocation(mount: Mount): ComputedLocation { - val ra = computeLST(mount) - val equatorial = Ecliptic.rotationAt(CurrentTime) * CartesianCoordinate.of(ra, 0.0, 1.0) - return computeLocation(mount, ra, SphericalCoordinate.of(equatorial).latitude, j2000 = false, meridianAt = false) + val equatorial = Ecliptic.rotationAt(CurrentTime) * CartesianCoordinate.of(computeLST(mount), 0.0, 1.0) + val (rightAscension, declination) = SphericalCoordinate.of(equatorial) + return computeLocation(mount, rightAscension, declination, j2000 = false, meridianAt = false) + } + + fun computeEquatorEclipticLocation(mount: Mount): ComputedLocation { + val a = computeLocation(mount, PI, 0.0, j2000 = false, meridianAt = false) + val b = computeLocation(mount, 0.0, 0.0, j2000 = false, meridianAt = false) + return if (a.altitude >= b.altitude) a else b } fun computeLocation( @@ -229,6 +238,26 @@ class MountService(private val imageBucket: ImageBucket) { } } + fun remoteControlStart(mount: Mount, type: MountRemoteControlType, host: String, port: Int) { + check(remoteControls.none { it.mount === mount && it.type == type }) { "$type ${mount.name} Remote Control is already running" } + + val server = if (type == MountRemoteControlType.STELLARIUM) StellariumProtocolServer(host, port) + else LX200ProtocolServer(host, port) + + server.run() + + remoteControls.add(MountRemoteControl(type, server, mount)) + } + + fun remoteControlStop(mount: Mount, type: MountRemoteControlType) { + val remoteControl = remoteControls.find { it.mount === mount && it.type == type } ?: return + remoteControl.use(remoteControls::remove) + } + + fun remoteControlList(mount: Mount): List { + return remoteControls.filter { it.mount === mount } + } + companion object { private const val SIDEREAL_TIME_DIFF = 0.06552777 * PI / 12.0 diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt deleted file mode 100644 index 69991f3be..000000000 --- a/api/src/main/kotlin/nebulosa/api/mounts/MountSlewStep.kt +++ /dev/null @@ -1,94 +0,0 @@ -package nebulosa.api.mounts - -import nebulosa.batch.processing.Step -import nebulosa.batch.processing.StepExecution -import nebulosa.batch.processing.StepResult -import nebulosa.batch.processing.delay.DelayStep -import nebulosa.common.concurrency.latch.CountUpDownLatch -import nebulosa.indi.device.mount.Mount -import nebulosa.indi.device.mount.MountEvent -import nebulosa.indi.device.mount.MountSlewFailed -import nebulosa.indi.device.mount.MountSlewingChanged -import nebulosa.log.loggerFor -import nebulosa.math.Angle -import nebulosa.math.formatHMS -import nebulosa.math.formatSignedDMS -import org.greenrobot.eventbus.EventBus -import org.greenrobot.eventbus.Subscribe -import org.greenrobot.eventbus.ThreadMode -import java.time.Duration - -data class MountSlewStep( - val mount: Mount, - val rightAscension: Angle, val declination: Angle, - val j2000: Boolean = false, val goTo: Boolean = true, -) : Step { - - private val latch = CountUpDownLatch() - private val settleDelayStep = DelayStep(SETTLE_DURATION) - - @Volatile private var initialRA = mount.rightAscension - @Volatile private var initialDEC = mount.declination - - @Subscribe(threadMode = ThreadMode.ASYNC) - fun onMountEvent(event: MountEvent) { - if (event.device === mount) { - if (event is MountSlewingChanged) { - if (!mount.slewing && (mount.rightAscension != initialRA || mount.declination != initialDEC)) { - latch.reset() - } - } else if (event is MountSlewFailed) { - LOG.warn("failed to slew mount. mount={}", mount) - latch.reset() - } - } - } - - override fun execute(stepExecution: StepExecution): StepResult { - if (mount.connected && !mount.parked && !mount.parking && !mount.slewing && - rightAscension.isFinite() && declination.isFinite() && - (mount.rightAscension != rightAscension || mount.declination != declination) - ) { - EventBus.getDefault().register(this) - - latch.countUp() - - LOG.info("moving mount. mount={}, ra={}, dec={}", mount, rightAscension.formatHMS(), declination.formatSignedDMS()) - - initialRA = mount.rightAscension - initialDEC = mount.declination - - if (j2000) { - if (goTo) mount.goToJ2000(rightAscension, declination) - else mount.slewToJ2000(rightAscension, declination) - } else { - if (goTo) mount.goTo(rightAscension, declination) - else mount.slewTo(rightAscension, declination) - } - - latch.await() - - LOG.info("mount moved. mount={}", mount) - - settleDelayStep.execute(stepExecution) - - EventBus.getDefault().unregister(this) - } else { - LOG.warn("cannot move mount. mount={}", mount) - } - - return StepResult.FINISHED - } - - override fun stop(mayInterruptIfRunning: Boolean) { - mount.abortMotion() - latch.reset() - settleDelayStep.stop(mayInterruptIfRunning) - } - - companion object { - - @JvmStatic private val LOG = loggerFor() - @JvmStatic private val SETTLE_DURATION: Duration = Duration.ofSeconds(5) - } -} diff --git a/api/src/main/kotlin/nebulosa/api/mounts/MountSlewTask.kt b/api/src/main/kotlin/nebulosa/api/mounts/MountSlewTask.kt new file mode 100644 index 000000000..01d9dd9d8 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/mounts/MountSlewTask.kt @@ -0,0 +1,100 @@ +package nebulosa.api.mounts + +import nebulosa.api.tasks.Task +import nebulosa.api.tasks.delay.DelayTask +import nebulosa.common.concurrency.cancel.CancellationListener +import nebulosa.common.concurrency.cancel.CancellationSource +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.common.concurrency.latch.CountUpDownLatch +import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.mount.MountEvent +import nebulosa.indi.device.mount.MountSlewFailed +import nebulosa.indi.device.mount.MountSlewingChanged +import nebulosa.log.loggerFor +import nebulosa.math.Angle +import nebulosa.math.formatHMS +import nebulosa.math.formatSignedDMS +import java.time.Duration + +data class MountSlewTask( + @JvmField val mount: Mount, + @JvmField val rightAscension: Angle, @JvmField val declination: Angle, + @JvmField val j2000: Boolean = false, @JvmField val goTo: Boolean = true, +) : Task, CancellationListener { + + private val delayTask = DelayTask(SETTLE_DURATION) + private val latch = CountUpDownLatch() + + @Volatile private var initialRA = mount.rightAscension + @Volatile private var initialDEC = mount.declination + + fun handleMountEvent(event: MountEvent) { + if (event.device === mount) { + if (event is MountSlewingChanged) { + if (!mount.slewing && (mount.rightAscension != initialRA || mount.declination != initialDEC)) { + latch.reset() + } + } else if (event is MountSlewFailed) { + LOG.warn("failed to slew mount. mount={}", mount) + latch.reset() + } + } + } + + override fun execute(cancellationToken: CancellationToken) { + if (!cancellationToken.isDone && + mount.connected && !mount.parked && !mount.parking && !mount.slewing && + rightAscension.isFinite() && declination.isFinite() && + (mount.rightAscension != rightAscension || mount.declination != declination) + ) { + latch.countUp() + + LOG.info("Mount Slew started. mount={}, ra={}, dec={}", mount, rightAscension.formatHMS(), declination.formatSignedDMS()) + + initialRA = mount.rightAscension + initialDEC = mount.declination + + try { + cancellationToken.listen(this) + + if (j2000) { + if (goTo) mount.goToJ2000(rightAscension, declination) + else mount.slewToJ2000(rightAscension, declination) + } else { + if (goTo) mount.goTo(rightAscension, declination) + else mount.slewTo(rightAscension, declination) + } + + latch.await() + } finally { + cancellationToken.unlisten(this) + } + + LOG.info("Mount Slew finished. mount={}, ra={}, dec={}", mount, rightAscension.formatHMS(), declination.formatSignedDMS()) + + delayTask.execute(cancellationToken) + } else { + LOG.warn("cannot slew mount. mount={}, ra={}, dec={}", mount, rightAscension.formatHMS(), declination.formatSignedDMS()) + } + } + + fun stop() { + mount.abortMotion() + latch.reset() + } + + override fun onCancel(source: CancellationSource) { + stop() + } + + override fun close() { + delayTask.close() + super.close() + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + @JvmStatic private val SETTLE_DURATION: Duration = Duration.ofSeconds(5) + } +} diff --git a/api/src/main/kotlin/nebulosa/api/rotators/RotatorController.kt b/api/src/main/kotlin/nebulosa/api/rotators/RotatorController.kt new file mode 100644 index 000000000..264353875 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/rotators/RotatorController.kt @@ -0,0 +1,69 @@ +package nebulosa.api.rotators + +import jakarta.validation.Valid +import nebulosa.api.connection.ConnectionService +import nebulosa.indi.device.rotator.Rotator +import org.hibernate.validator.constraints.Range +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("rotators") +class RotatorController( + private val connectionService: ConnectionService, + private val rotatorService: RotatorService, +) { + + @GetMapping + fun rotators(): List { + return connectionService.rotators().sorted() + } + + @GetMapping("{rotator}") + fun rotator(rotator: Rotator): Rotator { + return rotator + } + + @PutMapping("{rotator}/connect") + fun connect(rotator: Rotator) { + rotatorService.connect(rotator) + } + + @PutMapping("{rotator}/disconnect") + fun disconnect(rotator: Rotator) { + rotatorService.disconnect(rotator) + } + + @PutMapping("{rotator}/reverse") + fun reverse( + rotator: Rotator, + @RequestParam enabled: Boolean, + ) { + rotatorService.reverse(rotator, enabled) + } + + @PutMapping("{rotator}/move") + fun move( + rotator: Rotator, + @RequestParam @Valid @Range(min = 0, max = 360) angle: Double, + ) { + rotatorService.move(rotator, angle) + } + + @PutMapping("{rotator}/abort") + fun abort(rotator: Rotator) { + rotatorService.abort(rotator) + } + + @PutMapping("{rotator}/home") + fun home(rotator: Rotator) { + rotatorService.home(rotator) + } + + @PutMapping("{rotator}/sync") + fun sync( + rotator: Rotator, + @RequestParam @Valid @Range(min = 0, max = 360) angle: Double, + ) { + rotatorService.sync(rotator, angle) + } +} diff --git a/api/src/main/kotlin/nebulosa/api/rotators/RotatorDeserializer.kt b/api/src/main/kotlin/nebulosa/api/rotators/RotatorDeserializer.kt new file mode 100644 index 000000000..8251893c3 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/rotators/RotatorDeserializer.kt @@ -0,0 +1,16 @@ +package nebulosa.api.rotators + +import nebulosa.api.beans.converters.device.DeviceDeserializer +import nebulosa.api.connection.ConnectionService +import nebulosa.indi.device.rotator.Rotator +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Lazy +import org.springframework.stereotype.Component + +@Component +class RotatorDeserializer : DeviceDeserializer(Rotator::class.java) { + + @Autowired @Lazy private lateinit var connectionService: ConnectionService + + override fun deviceFor(name: String) = connectionService.rotator(name) +} diff --git a/api/src/main/kotlin/nebulosa/api/rotators/RotatorEventHandler.kt b/api/src/main/kotlin/nebulosa/api/rotators/RotatorEventHandler.kt new file mode 100644 index 000000000..b4fd46e07 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/rotators/RotatorEventHandler.kt @@ -0,0 +1,59 @@ +package nebulosa.api.rotators + +import io.reactivex.rxjava3.subjects.PublishSubject +import nebulosa.api.beans.annotations.Subscriber +import nebulosa.api.messages.MessageService +import nebulosa.indi.device.PropertyChangedEvent +import nebulosa.indi.device.rotator.Rotator +import nebulosa.indi.device.rotator.RotatorAttached +import nebulosa.indi.device.rotator.RotatorDetached +import nebulosa.indi.device.rotator.RotatorEvent +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.springframework.stereotype.Component +import java.io.Closeable +import java.util.concurrent.TimeUnit + +@Component +@Subscriber +class RotatorEventHandler( + private val messageService: MessageService, +) : Closeable { + + private val throttler = PublishSubject.create() + + init { + throttler + .throttleLast(1000, TimeUnit.MILLISECONDS) + .subscribe { sendUpdate(it.device!!) } + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + fun onRotatorEvent(event: RotatorEvent) { + when (event) { + is PropertyChangedEvent -> throttler.onNext(event) + is RotatorAttached -> sendMessage(ROTATOR_ATTACHED, event.device) + is RotatorDetached -> sendMessage(ROTATOR_DETACHED, event.device) + } + } + + @Suppress("NOTHING_TO_INLINE") + private inline fun sendMessage(eventName: String, device: Rotator) { + messageService.sendMessage(RotatorMessageEvent(eventName, device)) + } + + fun sendUpdate(device: Rotator) { + sendMessage(ROTATOR_UPDATED, device) + } + + override fun close() { + throttler.onComplete() + } + + companion object { + + const val ROTATOR_UPDATED = "ROTATOR.UPDATED" + const val ROTATOR_ATTACHED = "ROTATOR.ATTACHED" + const val ROTATOR_DETACHED = "ROTATOR.DETACHED" + } +} diff --git a/api/src/main/kotlin/nebulosa/api/rotators/RotatorMessageEvent.kt b/api/src/main/kotlin/nebulosa/api/rotators/RotatorMessageEvent.kt new file mode 100644 index 000000000..cdb79f400 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/rotators/RotatorMessageEvent.kt @@ -0,0 +1,9 @@ +package nebulosa.api.rotators + +import nebulosa.api.messages.DeviceMessageEvent +import nebulosa.indi.device.rotator.Rotator + +data class RotatorMessageEvent( + override val eventName: String, + override val device: Rotator, +) : DeviceMessageEvent diff --git a/api/src/main/kotlin/nebulosa/api/rotators/RotatorSerializer.kt b/api/src/main/kotlin/nebulosa/api/rotators/RotatorSerializer.kt new file mode 100644 index 000000000..851b48561 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/rotators/RotatorSerializer.kt @@ -0,0 +1,30 @@ +package nebulosa.api.rotators + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import nebulosa.indi.device.rotator.Rotator +import org.springframework.stereotype.Component + +@Component +class RotatorSerializer : StdSerializer(Rotator::class.java) { + + override fun serialize(value: Rotator, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeStartObject() + gen.writeStringField("sender", value.sender.id) + gen.writeStringField("id", value.id) + gen.writeStringField("name", value.name) + gen.writeBooleanField("connected", value.connected) + gen.writeBooleanField("moving", value.moving) + gen.writeNumberField("angle", value.angle) + gen.writeBooleanField("canAbort", value.canAbort) + gen.writeBooleanField("canReverse", value.canReverse) + gen.writeBooleanField("reversed", value.reversed) + gen.writeBooleanField("canHome", value.canHome) + gen.writeBooleanField("canSync", value.canSync) + gen.writeBooleanField("hasBacklashCompensation", value.hasBacklashCompensation) + gen.writeNumberField("maxAngle", value.maxAngle) + gen.writeNumberField("minAngle", value.minAngle) + gen.writeEndObject() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/rotators/RotatorService.kt b/api/src/main/kotlin/nebulosa/api/rotators/RotatorService.kt new file mode 100644 index 000000000..80d7537fb --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/rotators/RotatorService.kt @@ -0,0 +1,36 @@ +package nebulosa.api.rotators + +import nebulosa.indi.device.rotator.Rotator +import org.springframework.stereotype.Service + +@Service +class RotatorService { + + fun connect(rotator: Rotator) { + rotator.connect() + } + + fun disconnect(rotator: Rotator) { + rotator.disconnect() + } + + fun reverse(rotator: Rotator, enabled: Boolean) { + rotator.reverseRotator(enabled) + } + + fun move(rotator: Rotator, angle: Double) { + rotator.moveRotator(angle) + } + + fun abort(rotator: Rotator) { + rotator.abortRotator() + } + + fun sync(rotator: Rotator, angle: Double) { + rotator.syncRotator(angle) + } + + fun home(rotator: Rotator) { + rotator.homeRotator() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/AutoFocusAfterConditions.kt b/api/src/main/kotlin/nebulosa/api/sequencer/AutoFocusAfterConditions.kt index b72077084..9fc874590 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/AutoFocusAfterConditions.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/AutoFocusAfterConditions.kt @@ -8,17 +8,18 @@ import java.time.Duration import java.time.temporal.ChronoUnit data class AutoFocusAfterConditions( - val enabled: Boolean = false, - val onStart: Boolean = false, - val onFilterChange: Boolean = false, - @field:DurationMin(seconds = 0) @field:DurationMax(hours = 8) @field:DurationUnit(ChronoUnit.SECONDS) val afterElapsedTime: Duration = Duration.ZERO, - @field:PositiveOrZero val afterExposures: Int = 0, - @field:PositiveOrZero val afterTemperatureChange: Double = 0.0, - @field:PositiveOrZero val afterHFDIncrease: Double = 0.0, - val afterElapsedTimeEnabled: Boolean = false, - val afterExposuresEnabled: Boolean = false, - val afterTemperatureChangeEnabled: Boolean = false, - val afterHFDIncreaseEnabled: Boolean = false, + @JvmField val enabled: Boolean = false, + @JvmField val onStart: Boolean = false, + @JvmField val onFilterChange: Boolean = false, + @field:DurationMin(seconds = 0) @field:DurationMax(hours = 8) @field:DurationUnit(ChronoUnit.SECONDS) + @JvmField val afterElapsedTime: Duration = Duration.ZERO, + @field:PositiveOrZero @JvmField val afterExposures: Int = 0, + @field:PositiveOrZero @JvmField val afterTemperatureChange: Double = 0.0, + @field:PositiveOrZero @JvmField val afterHFDIncrease: Double = 0.0, + @JvmField val afterElapsedTimeEnabled: Boolean = false, + @JvmField val afterExposuresEnabled: Boolean = false, + @JvmField val afterTemperatureChangeEnabled: Boolean = false, + @JvmField val afterHFDIncreaseEnabled: Boolean = false, ) { companion object { diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/JobExecutionEvent.kt b/api/src/main/kotlin/nebulosa/api/sequencer/JobExecutionEvent.kt deleted file mode 100644 index 4c772494e..000000000 --- a/api/src/main/kotlin/nebulosa/api/sequencer/JobExecutionEvent.kt +++ /dev/null @@ -1,8 +0,0 @@ -package nebulosa.api.sequencer - -import nebulosa.batch.processing.JobExecution - -interface JobExecutionEvent { - - val jobExecution: JobExecution -} diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencePlanRequest.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencePlanRequest.kt index 5f1a7bcdc..110ab3942 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencePlanRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencePlanRequest.kt @@ -13,11 +13,11 @@ import java.time.Duration import java.time.temporal.ChronoUnit data class SequencePlanRequest( - @field:DurationUnit(ChronoUnit.SECONDS) @field:DurationMin(seconds = 0) @field:DurationMax(minutes = 60) val initialDelay: Duration = Duration.ZERO, - val captureMode: SequenceCaptureMode = SequenceCaptureMode.INTERLEAVED, - val autoSubFolderMode: AutoSubFolderMode = AutoSubFolderMode.OFF, - val savePath: Path? = null, - @field:NotEmpty val entries: List = emptyList(), - @field:Valid val dither: DitherAfterExposureRequest = DitherAfterExposureRequest.DISABLED, - @field:Valid val autoFocus: AutoFocusAfterConditions = AutoFocusAfterConditions.DISABLED, + @JvmField @field:DurationUnit(ChronoUnit.SECONDS) @field:DurationMin(seconds = 0) @field:DurationMax(minutes = 60) val initialDelay: Duration = Duration.ZERO, + @JvmField val captureMode: SequenceCaptureMode = SequenceCaptureMode.INTERLEAVED, + @JvmField val autoSubFolderMode: AutoSubFolderMode = AutoSubFolderMode.OFF, + @JvmField val savePath: Path? = null, + @JvmField @field:NotEmpty val entries: List = emptyList(), + @JvmField @field:Valid val dither: DitherAfterExposureRequest = DitherAfterExposureRequest.DISABLED, + @JvmField @field:Valid val autoFocus: AutoFocusAfterConditions = AutoFocusAfterConditions.DISABLED, ) diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt index 67ed86dc8..fd36445da 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerController.kt @@ -1,12 +1,11 @@ package nebulosa.api.sequencer import jakarta.validation.Valid -import nebulosa.api.beans.converters.device.DeviceOrEntityParam import nebulosa.indi.device.camera.Camera -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.indi.device.focuser.Focuser +import nebulosa.indi.device.mount.Mount +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("sequencer") @@ -15,13 +14,19 @@ class SequencerController( ) { @PutMapping("{camera}/start") - fun startSequencer( - @DeviceOrEntityParam camera: Camera, + fun start( + camera: Camera, + mount: Mount?, wheel: FilterWheel?, focuser: Focuser?, @RequestBody @Valid body: SequencePlanRequest, - ) = sequencerService.start(camera, body) + ) = sequencerService.start(camera, body, mount, wheel, focuser) @PutMapping("{camera}/stop") - fun stopSequencer(@DeviceOrEntityParam camera: Camera) { + fun stop(camera: Camera) { sequencerService.stop(camera) } + + @GetMapping("{camera}/status") + fun status(camera: Camera): SequencerEvent? { + return sequencerService.status(camera) + } } diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerElapsed.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerElapsed.kt deleted file mode 100644 index 63d1ce089..000000000 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerElapsed.kt +++ /dev/null @@ -1,16 +0,0 @@ -package nebulosa.api.sequencer - -import nebulosa.api.cameras.CameraCaptureElapsed -import nebulosa.api.messages.MessageEvent -import java.time.Duration - -data class SequencerElapsed( - val id: Int, - val elapsedTime: Duration, - val remainingTime: Duration, - val progress: Double, - val capture: CameraCaptureElapsed? = null, -) : MessageEvent { - - override val eventName = "SEQUENCER.ELAPSED" -} diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerEvent.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerEvent.kt new file mode 100644 index 000000000..2d8b2b627 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerEvent.kt @@ -0,0 +1,16 @@ +package nebulosa.api.sequencer + +import nebulosa.api.cameras.CameraCaptureEvent +import nebulosa.api.messages.MessageEvent +import java.time.Duration + +data class SequencerEvent( + @JvmField val id: Int = 0, + @JvmField val elapsedTime: Duration = Duration.ZERO, + @JvmField val remainingTime: Duration = Duration.ZERO, + @JvmField val progress: Double = 0.0, + @JvmField val capture: CameraCaptureEvent? = null, +) : MessageEvent { + + override val eventName = "SEQUENCER.ELAPSED" +} diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt index f4af5f833..23465bbb9 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerExecutor.kt @@ -1,44 +1,82 @@ package nebulosa.api.sequencer +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.beans.annotations.Subscriber +import nebulosa.api.messages.MessageEvent import nebulosa.api.messages.MessageService -import nebulosa.batch.processing.JobExecutor -import nebulosa.batch.processing.JobLauncher import nebulosa.guiding.Guider import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.camera.CameraEvent import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.indi.device.filterwheel.FilterWheelEvent import nebulosa.indi.device.focuser.Focuser -import nebulosa.log.info -import nebulosa.log.loggerFor +import nebulosa.indi.device.focuser.FocuserEvent +import nebulosa.indi.device.mount.Mount +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor import org.springframework.stereotype.Component +import java.util.concurrent.ConcurrentHashMap @Component +@Subscriber class SequencerExecutor( private val messageService: MessageService, private val guider: Guider, - override val jobLauncher: JobLauncher, -) : JobExecutor() { + private val threadPoolTaskExecutor: ThreadPoolTaskExecutor, +) : Consumer { + + private val jobs = ConcurrentHashMap.newKeySet(1) + + @Subscribe(threadMode = ThreadMode.ASYNC) + fun onCameraEvent(event: CameraEvent) { + jobs.find { it.task.camera === event.device }?.handleCameraEvent(event) + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + fun onFilterWheelEvent(event: FilterWheelEvent) { + jobs.find { it.task.wheel === event.device }?.handleFilterWheelEvent(event) + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + fun onFocuserEvent(event: FocuserEvent) { + // jobs.find { it.task.focuser === event.device }?.handleFocuserEvent(event) + } + + override fun accept(event: MessageEvent) { + messageService.sendMessage(event) + } fun execute( camera: Camera, request: SequencePlanRequest, - wheel: FilterWheel? = null, focuser: Focuser? = null, - ): String { - check(findJobExecutionWithAny(camera) == null) { "job is already running" } + mount: Mount? = null, wheel: FilterWheel? = null, focuser: Focuser? = null, + ) { + check(camera.connected) { "${camera.name} Camera is not connected" } + check(jobs.none { it.task.camera === camera }) { "${camera.name} Sequencer Job is already in progress" } - LOG.info { "starting sequencer. camera=$camera, wheel=$wheel, focuser=$focuser, request=$request" } + if (wheel != null && wheel.connected) { + check(jobs.none { it.task.wheel === wheel }) { "${camera.name} Sequencer Job is already in progress" } + } - val sequencerJob = SequencerJob(camera, request, guider, wheel, focuser) - sequencerJob.subscribe(messageService::sendMessage) - sequencerJob.initialize() - register(jobLauncher.launch(sequencerJob)) - return sequencerJob.id + if (focuser != null && focuser.connected) { + check(jobs.none { it.task.focuser === focuser }) { "${camera.name} Sequencer Job is already in progress" } + } + + val task = SequencerTask(camera, request, guider, mount, wheel, focuser, threadPoolTaskExecutor) + task.subscribe(this) + + with(SequencerJob(task)) { + jobs.add(this) + whenComplete { _, _ -> jobs.remove(this) } + start() + } } fun stop(camera: Camera) { - findJobExecutionWithAny(camera)?.also { jobLauncher.stop(it) } + jobs.find { it.task.camera === camera }?.stop() } - companion object { - - @JvmStatic private val LOG = loggerFor() + fun status(camera: Camera): SequencerEvent? { + return jobs.find { it.task.camera === camera }?.task?.get() as? SequencerEvent } } diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt index 1f0bdb4fc..0eb144303 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerJob.kt @@ -1,222 +1,18 @@ package nebulosa.api.sequencer -import io.reactivex.rxjava3.subjects.PublishSubject -import nebulosa.api.cameras.* -import nebulosa.api.focusers.FocusOffsetStep -import nebulosa.api.guiding.DitherAfterExposureStep -import nebulosa.api.guiding.WaitForSettleStep -import nebulosa.api.messages.MessageEvent -import nebulosa.api.wheels.WheelStep -import nebulosa.batch.processing.* -import nebulosa.batch.processing.ExecutionContext.Companion.getDuration -import nebulosa.batch.processing.ExecutionContext.Companion.getDurationOrNull -import nebulosa.batch.processing.ExecutionContext.Companion.getInt -import nebulosa.batch.processing.delay.DelayStep -import nebulosa.batch.processing.delay.DelayStepListener -import nebulosa.guiding.Guider -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.camera.FrameType -import nebulosa.indi.device.filterwheel.FilterWheel -import nebulosa.indi.device.focuser.Focuser -import java.time.Duration -import java.time.LocalDateTime +import nebulosa.api.tasks.Job +import nebulosa.indi.device.camera.CameraEvent +import nebulosa.indi.device.filterwheel.FilterWheelEvent -// https://cdn.diffractionlimited.com/help/maximdl/Autosave_Sequence.htm -// https://nighttime-imaging.eu/docs/master/site/tabs/sequence/ -// https://nighttime-imaging.eu/docs/master/site/sequencer/advanced/advanced/ +data class SequencerJob(override val task: SequencerTask) : Job() { -data class SequencerJob( - @JvmField val camera: Camera, - @JvmField val plan: SequencePlanRequest, - @JvmField val guider: Guider, - @JvmField val wheel: FilterWheel? = null, - @JvmField val focuser: Focuser? = null, -) : SimpleJob(), PublishSubscribe, DelayStepListener { + override val name = "${task.camera.name} Sequencer Job" - private val cameraCaptureEventHandler = CameraCaptureEventHandler(this) - - @Volatile var estimatedCaptureTime = plan.initialDelay - private set - - override val subject = PublishSubject.create() - - @Synchronized - fun initialize() { - clear() - - val initialDelayStep = DelayStep(plan.initialDelay) - initialDelayStep.registerDelayStepListener(this) - register(initialDelayStep) - - val waitForSettleStep = WaitForSettleStep(guider) - - fun mapRequest(request: CameraStartCaptureRequest): CameraStartCaptureRequest { - return request.copy(savePath = plan.savePath, autoSave = true, autoSubFolderMode = plan.autoSubFolderMode) - } - - fun CameraStartCaptureRequest.wheelStep(): Step? { - return if (wheel != null) WheelStep(wheel, if (frameType == FrameType.DARK) shutterPosition else filterPosition) else null - } - - fun CameraStartCaptureRequest.focusStep(): Step? { - return if (focuser != null) FocusOffsetStep(focuser, focusOffset) else null - } - - val usedEntries = plan.entries.filter { it.enabled } - - require(usedEntries.isNotEmpty()) { "no entries found" } - - if (plan.captureMode == SequenceCaptureMode.FULLY || usedEntries.size == 1) { - for (i in usedEntries.indices) { - val request = mapRequest(usedEntries[i]) - val cameraExposureStep = CameraExposureStep(camera, request) - val delayStep = DelayStep(request.exposureDelay) - delayStep.registerDelayStepListener(cameraExposureStep) - val delayAndWaitForSettleStep = SimpleSplitStep(waitForSettleStep, delayStep) - val ditherStep = DitherAfterExposureStep(request.dither, guider) - val wheelStep = request.wheelStep() - val focusStep = request.focusStep() - var estimatedCaptureTimeForEntry = Duration.ZERO - - register(SequenceIdStep(plan.entries.indexOf(usedEntries[i]) + 1)) - - repeat(request.exposureAmount) { - if (i == 0 && it == 0) { - register(waitForSettleStep) - } else { - register(delayAndWaitForSettleStep) - estimatedCaptureTime += request.exposureDelay - estimatedCaptureTimeForEntry += request.exposureDelay - } - - wheelStep?.also(::register) - focusStep?.also(::register) - register(cameraExposureStep) - register(ditherStep) - - estimatedCaptureTime += request.exposureTime - estimatedCaptureTimeForEntry += request.exposureTime - } - - cameraExposureStep.registerCameraCaptureListener(cameraCaptureEventHandler) - cameraExposureStep.estimatedCaptureTime = estimatedCaptureTimeForEntry - } - } else { - val sequenceIdSteps = usedEntries.map { SequenceIdStep(plan.entries.indexOf(it) + 1) } - val requests = usedEntries.map(::mapRequest) - val count = IntArray(requests.size) - val delaySteps = requests.map { DelayStep(it.exposureDelay) } - val ditherSteps = requests.map { DitherAfterExposureStep(it.dither, guider) } - val cameraExposureSteps = requests.map { CameraExposureStep(camera, it) } - delaySteps.indices.forEach { delaySteps[it].registerDelayStepListener(cameraExposureSteps[it]) } - val delayAndWaitForSettleSteps = requests.indices.map { SimpleSplitStep(waitForSettleStep, delaySteps[it]) } - val wheelSteps = requests.map { it.wheelStep() } - val focusSteps = requests.map { it.focusStep() } - val estimatedCaptureTimeForEntry = Array(requests.size) { Duration.ZERO } - - while (true) { - var added = false - - for (i in usedEntries.indices) { - val request = requests[i] - - if (count[i] < request.exposureAmount) { - register(sequenceIdSteps[i]) - - if (i == 0 && count[i] == 0) { - register(waitForSettleStep) - } else { - register(delayAndWaitForSettleSteps[i]) - estimatedCaptureTime += delaySteps[i].duration - estimatedCaptureTimeForEntry[i] += delaySteps[i].duration - } - - wheelSteps[i]?.also(::register) - focusSteps[i]?.also(::register) - register(cameraExposureSteps[i]) - register(ditherSteps[i]) - - estimatedCaptureTime += cameraExposureSteps[i].exposureTime - estimatedCaptureTimeForEntry[i] += cameraExposureSteps[i].exposureTime - - count[i]++ - added = true - } - } - - if (!added) break - } - - cameraExposureSteps.forEach { it.registerCameraCaptureListener(cameraCaptureEventHandler) } - estimatedCaptureTimeForEntry.indices.forEach { cameraExposureSteps[it].estimatedCaptureTime = estimatedCaptureTimeForEntry[it] } - } - } - - override fun beforeJob(jobExecution: JobExecution) { - jobExecution.context[STARTED_AT] = LocalDateTime.now() - } - - override fun afterJob(jobExecution: JobExecution) { - val id = jobExecution.context.getInt(SequenceIdStep.ID) - super.onNext(SequencerElapsed(id, estimatedCaptureTime, Duration.ZERO, 1.0)) - } - - override fun onNext(event: MessageEvent) { - if (event is CameraCaptureElapsed) { - val context = event.jobExecution.context - val id = context.getInt(SequenceIdStep.ID) - - context["$ELAPSED_TIME.$id"] = event.captureElapsedTime - context["$REMAINING_TIME.$id"] = event.captureRemainingTime - context["$PROGRESS.$id"] = event.captureProgress - - var elapsedTime = plan.initialDelay - - for (i in 1..32) { - elapsedTime += context.getDurationOrNull("$ELAPSED_TIME.$i") ?: break - } - - val progress = elapsedTime.toMillis() / estimatedCaptureTime.toMillis().toDouble() - - super.onNext(SequencerElapsed(id, elapsedTime, estimatedCaptureTime - elapsedTime, progress, event)) - } - - if (event is CameraExposureFinished) { - super.onNext(event) - } - } - - override fun onDelayElapsed(step: DelayStep, stepExecution: StepExecution) { - val context = stepExecution.jobExecution.context - val remainingTime = context.getDuration(DelayStep.REMAINING_TIME) - val elapsedTime = step.duration - remainingTime - val progress = elapsedTime.toMillis() / estimatedCaptureTime.toMillis().toDouble() - - super.onNext(SequencerElapsed(0, elapsedTime, estimatedCaptureTime - elapsedTime, progress)) - } - - override fun contains(data: Any): Boolean { - return data === camera || data === focuser || data === wheel || super.contains(data) + fun handleCameraEvent(event: CameraEvent) { + task.handleCameraEvent(event) } - private data class SequenceIdStep(private val id: Int) : Step { - - override fun execute(stepExecution: StepExecution): StepResult { - stepExecution.context[ID] = id - return StepResult.FINISHED - } - - companion object { - - const val ID = "SEQUENCE.ID" - } - } - - companion object { - - const val STARTED_AT = "SEQUENCER.STARTED_AT" - const val ELAPSED_TIME = "SEQUENCER.ELAPSED_TIME" - const val REMAINING_TIME = "SEQUENCER.REMAINING_TIME" - const val PROGRESS = "SEQUENCER.PROGRESS" + fun handleFilterWheelEvent(event: FilterWheelEvent) { + task.handleFilterWheelEvent(event) } } diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt index b3840bcfe..fe26e8e24 100644 --- a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerService.kt @@ -1,6 +1,9 @@ package nebulosa.api.sequencer import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.indi.device.focuser.Focuser +import nebulosa.indi.device.mount.Mount import org.springframework.stereotype.Service import java.nio.file.Path import kotlin.io.path.exists @@ -13,17 +16,23 @@ class SequencerService( ) { @Synchronized - fun start(camera: Camera, request: SequencePlanRequest): String { + fun start( + camera: Camera, request: SequencePlanRequest, + mount: Mount?, wheel: FilterWheel?, focuser: Focuser?, + ) { val savePath = request.savePath ?.takeIf { "$it".isNotBlank() && it.exists() && it.isDirectory() } ?: Path.of("$sequencesPath", (System.currentTimeMillis() / 1000).toString()) - return sequencerExecutor - .execute(camera, request.copy(savePath = savePath)) + sequencerExecutor.execute(camera, request.copy(savePath = savePath), mount, wheel, focuser) } @Synchronized fun stop(camera: Camera) { sequencerExecutor.stop(camera) } + + fun status(camera: Camera): SequencerEvent? { + return sequencerExecutor.status(camera) + } } diff --git a/api/src/main/kotlin/nebulosa/api/sequencer/SequencerTask.kt b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerTask.kt new file mode 100644 index 000000000..f099436eb --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/sequencer/SequencerTask.kt @@ -0,0 +1,218 @@ +package nebulosa.api.sequencer + +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.cameras.CameraCaptureEvent +import nebulosa.api.cameras.CameraCaptureState +import nebulosa.api.cameras.CameraCaptureTask +import nebulosa.api.cameras.CameraStartCaptureRequest +import nebulosa.api.messages.MessageEvent +import nebulosa.api.tasks.AbstractTask +import nebulosa.api.tasks.Task +import nebulosa.api.tasks.delay.DelayEvent +import nebulosa.api.tasks.delay.DelayTask +import nebulosa.api.wheels.WheelMoveTask +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.guiding.Guider +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.camera.CameraEvent +import nebulosa.indi.device.camera.FrameType +import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.indi.device.filterwheel.FilterWheelEvent +import nebulosa.indi.device.focuser.Focuser +import nebulosa.indi.device.mount.Mount +import nebulosa.log.loggerFor +import java.time.Duration +import java.util.* +import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +// https://cdn.diffractionlimited.com/help/maximdl/Autosave_Sequence.htm +// https://nighttime-imaging.eu/docs/master/site/tabs/sequence/ +// https://nighttime-imaging.eu/docs/master/site/sequencer/advanced/advanced/ + +data class SequencerTask( + @JvmField val camera: Camera, + @JvmField val plan: SequencePlanRequest, + @JvmField val guider: Guider? = null, + @JvmField val mount: Mount? = null, + @JvmField val wheel: FilterWheel? = null, + @JvmField val focuser: Focuser? = null, + private val executor: Executor? = null, +) : AbstractTask(), Consumer { + + private val usedEntries = plan.entries.filter { it.enabled } + + private val initialDelayTask = DelayTask(plan.initialDelay) + + private val sequencerId = AtomicInteger() + private val tasks = LinkedList() + private val currentTask = AtomicReference() + + @Volatile private var estimatedCaptureTime = initialDelayTask.duration + + @Volatile private var elapsedTime = Duration.ZERO + @Volatile private var prevElapsedTime = Duration.ZERO + @Volatile private var remainingTime = Duration.ZERO + @Volatile private var progress = 0.0 + + init { + require(usedEntries.isNotEmpty()) { "no entries found" } + + initialDelayTask.subscribe(this) + tasks.add(initialDelayTask) + + fun mapRequest(request: CameraStartCaptureRequest): CameraStartCaptureRequest { + return request.copy(savePath = plan.savePath, autoSave = true, autoSubFolderMode = plan.autoSubFolderMode) + } + + if (plan.captureMode == SequenceCaptureMode.FULLY || usedEntries.size == 1) { + for (i in usedEntries.indices) { + val request = mapRequest(usedEntries[i]) + + // ID. + tasks.add(SequencerIdTask(plan.entries.indexOfFirst { it === usedEntries[i] } + 1)) + + // FILTER WHEEL. + request.wheelMoveTask()?.also(tasks::add) + + // CAPTURE. + val cameraCaptureTask = CameraCaptureTask(camera, request, guider, executor = executor) + cameraCaptureTask.subscribe(this) + estimatedCaptureTime += cameraCaptureTask.estimatedCaptureTime + tasks.add(cameraCaptureTask) + } + } else { + val sequenceIdTasks = usedEntries.map { req -> SequencerIdTask(plan.entries.indexOfFirst { it === req } + 1) } + val requests = usedEntries.map { mapRequest(it) } + val cameraCaptureTasks = requests.mapIndexed { i, req -> CameraCaptureTask(camera, req, guider, i > 0, 1, executor) } + val wheelMoveTasks = requests.map { it.wheelMoveTask() } + val count = IntArray(requests.size) { usedEntries[it].exposureAmount } + + for (task in cameraCaptureTasks) { + task.subscribe(this) + estimatedCaptureTime += task.estimatedCaptureTime + } + + while (count.sum() > 0) { + for (i in usedEntries.indices) { + if (count[i] > 0) { + count[i]-- + + tasks.add(sequenceIdTasks[i]) + wheelMoveTasks[i]?.also(tasks::add) + tasks.add(cameraCaptureTasks[i]) + } + } + } + } + } + + fun handleCameraEvent(event: CameraEvent) { + val task = currentTask.get() + + if (task is CameraCaptureTask) { + task.handleCameraEvent(event) + } + } + + fun handleFilterWheelEvent(event: FilterWheelEvent) { + val task = currentTask.get() + + if (task is WheelMoveTask) { + task.handleFilterWheelEvent(event) + } + } + + override fun execute(cancellationToken: CancellationToken) { + LOG.info("Sequencer started. camera={}, mount={}, wheel={}, focuser={}, plan={}", camera, mount, wheel, focuser, plan) + + camera.snoop(listOf(mount, wheel, focuser)) + + for (task in tasks) { + if (cancellationToken.isDone) break + currentTask.set(task) + task.execute(cancellationToken) + currentTask.set(null) + } + + if (remainingTime.toMillis() > 0L) { + remainingTime = Duration.ZERO + progress = 1.0 + sendEvent() + } + + LOG.info("Sequencer finished. camera={}, mount={}, wheel={}, focuser={}, plan={}", camera, mount, wheel, focuser, plan) + } + + private fun CameraStartCaptureRequest.wheelMoveTask(): WheelMoveTask? { + if (wheel != null) { + val filterPosition = if (frameType == FrameType.DARK) shutterPosition else filterPosition + + if (filterPosition in 1..wheel.count) { + return WheelMoveTask(wheel, filterPosition) + } + } + + return null + } + + override fun canUseAsLastEvent(event: MessageEvent) = event is SequencerEvent + + override fun accept(event: Any) { + when (event) { + is DelayEvent -> { + if (event.task === initialDelayTask) { + elapsedTime += event.waitTime + computeRemainingTimeAndProgress() + sendEvent() + } + } + is CameraCaptureEvent -> { + when (event.state) { + CameraCaptureState.CAPTURE_STARTED -> { + prevElapsedTime = elapsedTime + } + CameraCaptureState.EXPOSURING, CameraCaptureState.WAITING -> { + elapsedTime = prevElapsedTime + event.captureElapsedTime + computeRemainingTimeAndProgress() + } + CameraCaptureState.EXPOSURE_FINISHED -> { + onNext(event) + } + else -> Unit + } + + sendEvent(event) + } + } + } + + @Suppress("NOTHING_TO_INLINE") + private inline fun computeRemainingTimeAndProgress() { + remainingTime = if (estimatedCaptureTime > elapsedTime) estimatedCaptureTime - elapsedTime else Duration.ZERO + progress = (estimatedCaptureTime - remainingTime).toNanos().toDouble() / estimatedCaptureTime.toNanos() + } + + @Suppress("NOTHING_TO_INLINE") + private inline fun sendEvent(capture: CameraCaptureEvent? = null) { + onNext(SequencerEvent(sequencerId.get(), elapsedTime, remainingTime, progress, capture)) + } + + override fun close() { + tasks.forEach { it.close() } + } + + private inner class SequencerIdTask(private val id: Int) : Task { + + override fun execute(cancellationToken: CancellationToken) { + LOG.info("Sequence started. id={}", id) + sequencerId.set(id) + } + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt index 22daca0b9..0e3c8be85 100644 --- a/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt +++ b/api/src/main/kotlin/nebulosa/api/solver/PlateSolverOptions.kt @@ -8,12 +8,13 @@ import java.time.Duration import java.time.temporal.ChronoUnit data class PlateSolverOptions( - val type: PlateSolverType = PlateSolverType.ASTROMETRY_NET_ONLINE, - val executablePath: Path? = null, - val downsampleFactor: Int = 0, - val apiUrl: String = "", - val apiKey: String = "", - @field:DurationMin(seconds = 0) @field:DurationMax(minutes = 5) @field:DurationUnit(ChronoUnit.SECONDS) val timeout: Duration = Duration.ZERO, + @JvmField val type: PlateSolverType = PlateSolverType.ASTROMETRY_NET_ONLINE, + @JvmField val executablePath: Path? = null, + @JvmField val downsampleFactor: Int = 0, + @JvmField val apiUrl: String = "", + @JvmField val apiKey: String = "", + @field:DurationMin(seconds = 0) @field:DurationMax(minutes = 5) @field:DurationUnit(ChronoUnit.SECONDS) + @JvmField val timeout: Duration = Duration.ZERO, ) { companion object { diff --git a/api/src/main/kotlin/nebulosa/api/tasks/AbstractTask.kt b/api/src/main/kotlin/nebulosa/api/tasks/AbstractTask.kt new file mode 100644 index 000000000..676439b12 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/tasks/AbstractTask.kt @@ -0,0 +1,31 @@ +package nebulosa.api.tasks + +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Observer +import java.util.concurrent.atomic.AtomicReference + +abstract class AbstractTask : Observable(), ObservableTask { + + private val observers = LinkedHashSet>(1) + private val lastEvent = AtomicReference() + + protected open fun canUseAsLastEvent(event: T) = true + + final override fun get(): T? = lastEvent.get() + + final override fun subscribeActual(observer: Observer) { + observers.add(observer) + } + + protected fun onNext(event: T) { + if (canUseAsLastEvent(event)) { + lastEvent.set(event) + } + + observers.forEach { it.onNext(event) } + } + + override fun close() { + observers.forEach { it.onComplete() } + } +} diff --git a/api/src/main/kotlin/nebulosa/api/tasks/Job.kt b/api/src/main/kotlin/nebulosa/api/tasks/Job.kt new file mode 100644 index 000000000..d733d654f --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/tasks/Job.kt @@ -0,0 +1,66 @@ +package nebulosa.api.tasks + +import nebulosa.common.concurrency.cancel.CancellationToken +import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean + +abstract class Job : CompletableFuture(), Runnable { + + abstract val task: Task + + abstract val name: String + + private val cancellationToken = CancellationToken() + private val running = AtomicBoolean() + + @Volatile private var thread: Thread? = null + + val isRunning + get() = running.get() + + final override fun run() { + try { + running.set(true) + task.execute(cancellationToken) + } finally { + running.set(false) + thread = null + cancellationToken.close() + complete(Unit) + task.close() + } + } + + /** + * Runs this Job in a new thread. + */ + @Synchronized + fun start() { + if (thread == null && !running.get()) { + thread = Thread(this, name) + thread!!.isDaemon = true + thread!!.start() + } + } + + /** + * Stops gracefully this Job. + */ + fun stop() { + cancellationToken.cancel() + } + + /** + * Pauses this Job. + */ + fun pause() { + cancellationToken.pause() + } + + /** + * Unpauses this Job. + */ + fun unpause() { + cancellationToken.unpause() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/tasks/ObservableTask.kt b/api/src/main/kotlin/nebulosa/api/tasks/ObservableTask.kt new file mode 100644 index 000000000..e04a941c4 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/tasks/ObservableTask.kt @@ -0,0 +1,6 @@ +package nebulosa.api.tasks + +import io.reactivex.rxjava3.core.ObservableSource +import java.util.function.Supplier + +interface ObservableTask : Task, ObservableSource, Supplier diff --git a/api/src/main/kotlin/nebulosa/api/tasks/SplitTask.kt b/api/src/main/kotlin/nebulosa/api/tasks/SplitTask.kt new file mode 100644 index 000000000..9de7e082c --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/tasks/SplitTask.kt @@ -0,0 +1,32 @@ +package nebulosa.api.tasks + +import nebulosa.common.concurrency.cancel.CancellationToken +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executor +import java.util.concurrent.ForkJoinPool + +data class SplitTask( + private val tasks: Collection, + private val executor: Executor? = null, +) : Task { + + override fun execute(cancellationToken: CancellationToken) { + if (tasks.isEmpty()) { + return + } else if (tasks.size == 1) { + tasks.first().execute(cancellationToken) + } else { + val completables = tasks.map { CompletableFuture.runAsync({ it.execute(cancellationToken) }, executor ?: EXECUTOR) } + completables.forEach(CompletableFuture<*>::join) + } + } + + override fun reset() { + tasks.forEach { it.reset() } + } + + companion object { + + @JvmStatic private val EXECUTOR = ForkJoinPool.commonPool() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/tasks/Task.kt b/api/src/main/kotlin/nebulosa/api/tasks/Task.kt new file mode 100644 index 000000000..924a048cf --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/tasks/Task.kt @@ -0,0 +1,27 @@ +package nebulosa.api.tasks + +import nebulosa.common.Resettable +import nebulosa.common.concurrency.cancel.CancellationToken +import java.io.Closeable + +interface Task : Resettable, Closeable { + + fun execute(cancellationToken: CancellationToken = CancellationToken.NONE) + + override fun reset() = Unit + + override fun close() = Unit + + companion object { + + @JvmStatic + fun of(vararg tasks: Task) = object : Task { + + override fun execute(cancellationToken: CancellationToken) = tasks.forEach { it.execute(cancellationToken) } + + override fun reset() = tasks.forEach { it.reset() } + + override fun close() = tasks.forEach { it.close() } + } + } +} diff --git a/api/src/main/kotlin/nebulosa/api/tasks/delay/DelayEvent.kt b/api/src/main/kotlin/nebulosa/api/tasks/delay/DelayEvent.kt new file mode 100644 index 000000000..f68e8119c --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/tasks/delay/DelayEvent.kt @@ -0,0 +1,10 @@ +package nebulosa.api.tasks.delay + +import java.time.Duration + +data class DelayEvent( + @JvmField val task: DelayTask, + @JvmField val remainingTime: Duration = Duration.ZERO, + @JvmField val waitTime: Duration = Duration.ZERO, + @JvmField val progress: Double = 0.0, +) diff --git a/api/src/main/kotlin/nebulosa/api/tasks/delay/DelayTask.kt b/api/src/main/kotlin/nebulosa/api/tasks/delay/DelayTask.kt new file mode 100644 index 000000000..af20bc2cf --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/tasks/delay/DelayTask.kt @@ -0,0 +1,61 @@ +package nebulosa.api.tasks.delay + +import nebulosa.api.tasks.AbstractTask +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.log.loggerFor +import java.time.Duration + +data class DelayTask( + @JvmField val duration: Duration, +) : AbstractTask() { + + @Volatile private var remainingTime = Duration.ZERO + @Volatile private var waitTime = Duration.ZERO + @Volatile private var progress = 0.0 + + override fun execute(cancellationToken: CancellationToken) { + val durationTime = duration.toMillis() + var remainingTime = durationTime + + if (!cancellationToken.isDone && remainingTime > 0L) { + LOG.info("Delay started. duration={}", remainingTime) + + while (!cancellationToken.isDone && remainingTime > 0L) { + val waitTime = minOf(remainingTime, DELAY_INTERVAL) + + if (waitTime > 0L) { + progress = (durationTime - remainingTime) / durationTime.toDouble() + this.remainingTime = Duration.ofMillis(remainingTime) + this.waitTime = Duration.ofMillis(waitTime) + sendEvent() + + Thread.sleep(waitTime) + + remainingTime -= waitTime + } + } + + this.remainingTime = Duration.ZERO + this.waitTime = Duration.ZERO + progress = 1.0 + + sendEvent() + } + } + + override fun reset() { + remainingTime = Duration.ZERO + waitTime = Duration.ZERO + progress = 0.0 + } + + private fun sendEvent() { + onNext(DelayEvent(this, remainingTime, waitTime, progress)) + } + + companion object { + + const val DELAY_INTERVAL = 500L + @JvmStatic private val LOG = loggerFor() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/wheels/WheelController.kt b/api/src/main/kotlin/nebulosa/api/wheels/WheelController.kt index 417204e75..a8adcc5ee 100644 --- a/api/src/main/kotlin/nebulosa/api/wheels/WheelController.kt +++ b/api/src/main/kotlin/nebulosa/api/wheels/WheelController.kt @@ -3,7 +3,6 @@ package nebulosa.api.wheels import jakarta.validation.Valid import jakarta.validation.constraints.NotEmpty import jakarta.validation.constraints.PositiveOrZero -import nebulosa.api.beans.converters.device.DeviceOrEntityParam import nebulosa.api.connection.ConnectionService import nebulosa.indi.device.filterwheel.FilterWheel import org.springframework.web.bind.annotation.* @@ -21,23 +20,23 @@ class WheelController( } @GetMapping("{wheel}") - fun wheel(@DeviceOrEntityParam wheel: FilterWheel): FilterWheel { + fun wheel(wheel: FilterWheel): FilterWheel { return wheel } @PutMapping("{wheel}/connect") - fun connect(@DeviceOrEntityParam wheel: FilterWheel) { + fun connect(wheel: FilterWheel) { wheelService.connect(wheel) } @PutMapping("{wheel}/disconnect") - fun disconnect(@DeviceOrEntityParam wheel: FilterWheel) { + fun disconnect(wheel: FilterWheel) { wheelService.disconnect(wheel) } @PutMapping("{wheel}/move-to") fun moveTo( - @DeviceOrEntityParam wheel: FilterWheel, + wheel: FilterWheel, @RequestParam @Valid @PositiveOrZero position: Int, ) { wheelService.moveTo(wheel, position) @@ -45,7 +44,7 @@ class WheelController( @PutMapping("{wheel}/sync") fun sync( - @DeviceOrEntityParam wheel: FilterWheel, + wheel: FilterWheel, @RequestParam @Valid @NotEmpty names: String, ) { wheelService.sync(wheel, names.split(",")) diff --git a/api/src/main/kotlin/nebulosa/api/wheels/WheelMoveTask.kt b/api/src/main/kotlin/nebulosa/api/wheels/WheelMoveTask.kt new file mode 100644 index 000000000..01340b1b9 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/wheels/WheelMoveTask.kt @@ -0,0 +1,56 @@ +package nebulosa.api.wheels + +import nebulosa.api.tasks.Task +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.common.concurrency.latch.CountUpDownLatch +import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.indi.device.filterwheel.FilterWheelEvent +import nebulosa.indi.device.filterwheel.FilterWheelMoveFailed +import nebulosa.indi.device.filterwheel.FilterWheelPositionChanged +import nebulosa.log.loggerFor + +data class WheelMoveTask( + @JvmField val wheel: FilterWheel, + @JvmField val position: Int, +) : Task { + + private val latch = CountUpDownLatch() + + @Volatile private var initialPosition = wheel.position + + fun handleFilterWheelEvent(event: FilterWheelEvent) { + if (event is FilterWheelPositionChanged) { + if (initialPosition != wheel.position && wheel.position == position) { + latch.reset() + } + } else if (event is FilterWheelMoveFailed) { + LOG.warn("failed to move filter wheel. wheel={}, position={}", wheel, position) + latch.reset() + } + } + + override fun execute(cancellationToken: CancellationToken) { + if (wheel.connected && position in 1..wheel.count && wheel.position != position) { + initialPosition = wheel.position + + LOG.info("Filter Wheel Move started. wheel={}, position={}", wheel, position) + + try { + cancellationToken.listen(latch) + latch.countUp() + wheel.moveTo(position) + latch.await() + } finally { + cancellationToken.unlisten(latch) + LOG.info("Filter Wheel Move finished. wheel={}, position={}", wheel, position) + } + } else { + LOG.warn("filter wheel not connected or invalid position. position={}, wheel={}", position, wheel) + } + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/wheels/WheelStep.kt b/api/src/main/kotlin/nebulosa/api/wheels/WheelStep.kt deleted file mode 100644 index caeefa4aa..000000000 --- a/api/src/main/kotlin/nebulosa/api/wheels/WheelStep.kt +++ /dev/null @@ -1,60 +0,0 @@ -package nebulosa.api.wheels - -import nebulosa.batch.processing.Step -import nebulosa.batch.processing.StepExecution -import nebulosa.batch.processing.StepResult -import nebulosa.common.concurrency.latch.CountUpDownLatch -import nebulosa.indi.device.filterwheel.FilterWheel -import nebulosa.indi.device.filterwheel.FilterWheelEvent -import nebulosa.indi.device.filterwheel.FilterWheelMoveFailed -import nebulosa.indi.device.filterwheel.FilterWheelPositionChanged -import nebulosa.log.loggerFor -import org.greenrobot.eventbus.EventBus -import org.greenrobot.eventbus.Subscribe -import org.greenrobot.eventbus.ThreadMode - -data class WheelStep( - val wheel: FilterWheel, - val position: Int, -) : Step { - - private val latch = CountUpDownLatch() - @Volatile private var initialPosition = wheel.position - - @Subscribe(threadMode = ThreadMode.ASYNC) - fun onFilterWheelEvent(event: FilterWheelEvent) { - if (event is FilterWheelPositionChanged) { - if (initialPosition != wheel.position && wheel.position == position) { - latch.reset() - } - } else if (event is FilterWheelMoveFailed) { - LOG.warn("failed to move filter wheel. wheel={}, position={}", wheel, position) - latch.reset() - } - } - - override fun execute(stepExecution: StepExecution): StepResult { - if (wheel.connected && position > 0 && wheel.position != position) { - initialPosition = wheel.position - - EventBus.getDefault().register(this) - - latch.countUp() - wheel.moveTo(position) - latch.await() - - EventBus.getDefault().unregister(this) - } - - return StepResult.FINISHED - } - - override fun stop(mayInterruptIfRunning: Boolean) { - latch.reset() - } - - companion object { - - @JvmStatic private val LOG = loggerFor() - } -} diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardController.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardController.kt index 5beecd7bd..cb782e2c3 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardController.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardController.kt @@ -1,12 +1,8 @@ package nebulosa.api.wizard.flat import jakarta.validation.Valid -import nebulosa.api.beans.converters.device.DeviceOrEntityParam import nebulosa.indi.device.camera.Camera -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("flat-wizard") @@ -15,12 +11,17 @@ class FlatWizardController( ) { @PutMapping("{camera}/start") - fun startCapture(@DeviceOrEntityParam camera: Camera, @RequestBody @Valid body: FlatWizardRequest) { - flatWizardService.startCapture(camera, body) + fun start(camera: Camera, @RequestBody @Valid body: FlatWizardRequest) { + flatWizardService.start(camera, body) } @PutMapping("{camera}/stop") - fun stopCapture(@DeviceOrEntityParam camera: Camera) { - flatWizardService.stopCapture(camera) + fun stop(camera: Camera) { + flatWizardService.stop(camera) + } + + @GetMapping("{camera}/status") + fun status(camera: Camera): FlatWizardEvent? { + return flatWizardService.status(camera) } } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardElapsed.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardElapsed.kt deleted file mode 100644 index f60a71466..000000000 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardElapsed.kt +++ /dev/null @@ -1,22 +0,0 @@ -package nebulosa.api.wizard.flat - -import nebulosa.api.cameras.CameraCaptureElapsed -import nebulosa.api.messages.MessageEvent -import java.nio.file.Path -import java.time.Duration - -sealed interface FlatWizardElapsed : MessageEvent { - - val state: FlatWizardState - - val exposureTime: Duration - - val capture: CameraCaptureElapsed? - - val savedPath: Path? - - val message: String - - override val eventName - get() = "FLAT_WIZARD.ELAPSED" -} diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardEvent.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardEvent.kt new file mode 100644 index 000000000..b29856645 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardEvent.kt @@ -0,0 +1,16 @@ +package nebulosa.api.wizard.flat + +import nebulosa.api.cameras.CameraCaptureEvent +import nebulosa.api.messages.MessageEvent +import java.nio.file.Path +import java.time.Duration + +data class FlatWizardEvent( + @JvmField val state: FlatWizardState = FlatWizardState.IDLE, + @JvmField val exposureTime: Duration = Duration.ZERO, + @JvmField val capture: CameraCaptureEvent? = null, + @JvmField val savedPath: Path? = null, +) : MessageEvent { + + override val eventName = "FLAT_WIZARD.ELAPSED" +} diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutionListener.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutionListener.kt deleted file mode 100644 index c86eab5f6..000000000 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutionListener.kt +++ /dev/null @@ -1,11 +0,0 @@ -package nebulosa.api.wizard.flat - -import java.nio.file.Path -import java.time.Duration - -interface FlatWizardExecutionListener { - - fun onFlatCaptured(step: FlatWizardStep, savedPath: Path, duration: Duration) - - fun onFlatFailed(step: FlatWizardStep) -} diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt index 23ca02631..49ac3b688 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardExecutor.kt @@ -1,39 +1,52 @@ package nebulosa.api.wizard.flat -import nebulosa.api.image.ImageBucket +import io.reactivex.rxjava3.functions.Consumer +import nebulosa.api.beans.annotations.Subscriber +import nebulosa.api.messages.MessageEvent import nebulosa.api.messages.MessageService -import nebulosa.batch.processing.JobExecutor -import nebulosa.batch.processing.JobLauncher import nebulosa.indi.device.camera.Camera -import nebulosa.log.info -import nebulosa.log.loggerFor +import nebulosa.indi.device.camera.CameraEvent +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode import org.springframework.stereotype.Component +import java.util.concurrent.ConcurrentHashMap @Component +@Subscriber class FlatWizardExecutor( private val messageService: MessageService, - override val jobLauncher: JobLauncher, - private val imageBucket: ImageBucket, -) : JobExecutor() { +) : Consumer { - fun execute(camera: Camera, request: FlatWizardRequest): String { + private val jobs = ConcurrentHashMap.newKeySet(1) + + @Subscribe(threadMode = ThreadMode.ASYNC) + fun onCameraEvent(event: CameraEvent) { + jobs.find { it.task.camera === event.device }?.handleCameraEvent(event) + } + + override fun accept(event: MessageEvent) { + messageService.sendMessage(event) + } + + fun execute(camera: Camera, request: FlatWizardRequest) { check(camera.connected) { "camera is not connected" } - check(findJobExecutionWithAny(camera) == null) { "job is already running for camera: [${camera.name}]" } + check(jobs.none { it.task.camera === camera }) { "${camera.name} Flat Wizard is already in progress" } - LOG.info { "starting flat wizard capture. camera=$camera, request=$request" } + val task = FlatWizardTask(camera, request) + task.subscribe(this) - val flatWizardJob = FlatWizardJob(camera, request, imageBucket = imageBucket) - flatWizardJob.subscribe(messageService::sendMessage) - register(jobLauncher.launch(flatWizardJob)) - return flatWizardJob.id + with(FlatWizardJob(task)) { + jobs.add(this) + whenComplete { _, _ -> jobs.remove(this) } + start() + } } fun stop(camera: Camera) { - findJobExecutionWithAny(camera)?.also { jobLauncher.stop(it) } + jobs.find { it.task.camera === camera }?.stop() } - companion object { - - @JvmStatic private val LOG = loggerFor() + fun status(camera: Camera): FlatWizardEvent? { + return jobs.find { it.task.camera === camera }?.task?.get() as? FlatWizardEvent } } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardFailed.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardFailed.kt deleted file mode 100644 index 6a862e6df..000000000 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardFailed.kt +++ /dev/null @@ -1,12 +0,0 @@ -package nebulosa.api.wizard.flat - -import java.time.Duration - -data object FlatWizardFailed : FlatWizardElapsed { - - override val state = FlatWizardState.FAILED - override val exposureTime: Duration = Duration.ZERO - override val capture = null - override val savedPath = null - override val message = "" -} diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardFrameCaptured.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardFrameCaptured.kt deleted file mode 100644 index 565df85f6..000000000 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardFrameCaptured.kt +++ /dev/null @@ -1,14 +0,0 @@ -package nebulosa.api.wizard.flat - -import java.nio.file.Path -import java.time.Duration - -data class FlatWizardFrameCaptured( - override val exposureTime: Duration, - override val savedPath: Path, -) : FlatWizardElapsed { - - override val state = FlatWizardState.CAPTURED - override val capture = null - override val message = "" -} diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardIsExposuring.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardIsExposuring.kt deleted file mode 100644 index a412c1289..000000000 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardIsExposuring.kt +++ /dev/null @@ -1,14 +0,0 @@ -package nebulosa.api.wizard.flat - -import nebulosa.api.cameras.CameraCaptureElapsed -import java.time.Duration - -data class FlatWizardIsExposuring( - override val exposureTime: Duration, - override val capture: CameraCaptureElapsed, -) : FlatWizardElapsed { - - override val state = FlatWizardState.EXPOSURING - override val savedPath = null - override val message = "" -} diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt index c73a491f1..05c21acc6 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardJob.kt @@ -1,56 +1,13 @@ package nebulosa.api.wizard.flat -import io.reactivex.rxjava3.subjects.PublishSubject -import nebulosa.api.cameras.CameraCaptureElapsed -import nebulosa.api.cameras.CameraCaptureEventHandler -import nebulosa.api.cameras.CameraExposureFinished -import nebulosa.api.image.ImageBucket -import nebulosa.api.messages.MessageEvent -import nebulosa.batch.processing.PublishSubscribe -import nebulosa.batch.processing.SimpleJob -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.filterwheel.FilterWheel -import java.nio.file.Path -import java.time.Duration +import nebulosa.api.tasks.Job +import nebulosa.indi.device.camera.CameraEvent -data class FlatWizardJob( - @JvmField val camera: Camera, - @JvmField val request: FlatWizardRequest, - @JvmField val wheel: FilterWheel? = null, - @JvmField val imageBucket: ImageBucket? = null, -) : SimpleJob(), PublishSubscribe, FlatWizardExecutionListener { +data class FlatWizardJob(override val task: FlatWizardTask) : Job() { - private val cameraCaptureEventHandler = CameraCaptureEventHandler(this) - private val step = FlatWizardStep(camera, request, imageBucket) + override val name = "${task.camera.name} Flat Wizard Job" - override val subject = PublishSubject.create() - - init { - step.registerCameraCaptureListener(cameraCaptureEventHandler) - step.registerFlatWizardExecutionListener(this) - register(step) - } - - override fun onFlatCaptured(step: FlatWizardStep, savedPath: Path, duration: Duration) { - super.onNext(FlatWizardFrameCaptured(duration, savedPath)) - } - - override fun onFlatFailed(step: FlatWizardStep) { - super.onNext(FlatWizardFailed) - } - - override fun onNext(event: MessageEvent) { - if (event is CameraCaptureElapsed) { - super.onNext(FlatWizardIsExposuring(step.exposureTime, event)) - - // Notify Camera window to retrieve new image. - if (event is CameraExposureFinished) { - super.onNext(event) - } - } - } - - override fun contains(data: Any): Boolean { - return data === camera || data === wheel || super.contains(data) + fun handleCameraEvent(event: CameraEvent) { + task.handleCameraEvent(event) } } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardRequest.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardRequest.kt index a8bcc24f5..92a214d3c 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardRequest.kt @@ -10,11 +10,11 @@ import java.time.Duration import java.time.temporal.ChronoUnit data class FlatWizardRequest( - @JsonIgnoreProperties("camera", "focuser", "dither") val captureRequest: CameraStartCaptureRequest, - @field:DurationMin(millis = 1) @field:DurationMax(minutes = 1) @field:DurationUnit(ChronoUnit.MILLIS) val exposureMin: Duration = MIN_EXPOSURE, - @field:DurationMin(millis = 1) @field:DurationMax(minutes = 1) @field:DurationUnit(ChronoUnit.MILLIS) val exposureMax: Duration = MAX_EXPOSURE, - @field:Range(min = 0, max = 65535) val meanTarget: Int = 32768, // 50% = 32768 (16-bit) - @field:Range(min = 0, max = 100) val meanTolerance: Int = 10, // 10% + @JsonIgnoreProperties("camera", "focuser", "dither") @JvmField val capture: CameraStartCaptureRequest, + @field:DurationMin(millis = 1) @field:DurationMax(minutes = 1) @field:DurationUnit(ChronoUnit.MILLIS) @JvmField val exposureMin: Duration = MIN_EXPOSURE, + @field:DurationMin(millis = 1) @field:DurationMax(minutes = 1) @field:DurationUnit(ChronoUnit.MILLIS) @JvmField val exposureMax: Duration = MAX_EXPOSURE, + @field:Range(min = 0, max = 65535) @JvmField val meanTarget: Int = 32768, // 50% = 32768 (16-bit) + @field:Range(min = 0, max = 100) @JvmField val meanTolerance: Int = 10, // 10% ) { companion object { diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardService.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardService.kt index a175a48d1..4ac313b61 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardService.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardService.kt @@ -13,17 +13,21 @@ class FlatWizardService( ) { @Synchronized - fun startCapture(camera: Camera, request: FlatWizardRequest) { - val savePath = request.captureRequest.savePath + fun start(camera: Camera, request: FlatWizardRequest) { + val savePath = request.capture.savePath ?.takeIf { "$it".isNotBlank() && it.exists() && it.isDirectory() } ?: Path.of("$capturesPath", camera.name, "FLAT") - flatWizardExecutor - .execute(camera, request.copy(captureRequest = request.captureRequest.copy(savePath = savePath))) + val capture = request.capture.copy(savePath = savePath) + flatWizardExecutor.execute(camera, request.copy(capture = capture)) } @Synchronized - fun stopCapture(camera: Camera) { + fun stop(camera: Camera) { flatWizardExecutor.stop(camera) } + + fun status(camera: Camera): FlatWizardEvent? { + return flatWizardExecutor.status(camera) + } } diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardState.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardState.kt index 06bc8ec4a..e5c1052bf 100644 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardState.kt +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardState.kt @@ -1,6 +1,7 @@ package nebulosa.api.wizard.flat enum class FlatWizardState { + IDLE, EXPOSURING, CAPTURED, FAILED, diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt deleted file mode 100644 index c71444fc7..000000000 --- a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardStep.kt +++ /dev/null @@ -1,130 +0,0 @@ -package nebulosa.api.wizard.flat - -import nebulosa.api.cameras.AutoSubFolderMode -import nebulosa.api.cameras.CameraCaptureListener -import nebulosa.api.cameras.CameraExposureStep -import nebulosa.api.image.ImageBucket -import nebulosa.batch.processing.Step -import nebulosa.batch.processing.StepExecution -import nebulosa.batch.processing.StepResult -import nebulosa.fits.fits -import nebulosa.image.Image -import nebulosa.image.algorithms.computation.Statistics -import nebulosa.image.format.ImageRepresentation -import nebulosa.indi.device.camera.Camera -import org.slf4j.LoggerFactory -import java.nio.file.Path -import java.time.Duration -import java.util.concurrent.atomic.AtomicReference - -data class FlatWizardStep( - @JvmField val camera: Camera, - @JvmField val request: FlatWizardRequest, - @JvmField val imageBucket: ImageBucket? = null, -) : Step { - - @Volatile var exposureMin = request.exposureMin - private set - - @Volatile var exposureMax = request.exposureMax - private set - - @Volatile var exposureTime: Duration = Duration.ZERO - private set - - @Volatile private var stopped = false - @Volatile private var image: Image? = null - - private val cameraExposureStep = AtomicReference() - private val flatWizardExecutionListeners = HashSet() - private val cameraCaptureListeners = HashSet() - private val meanTarget = request.meanTarget / 65535f - private val meanRange = (meanTarget * request.meanTolerance / 100f).let { (meanTarget - it)..(meanTarget + it) } - - fun registerFlatWizardExecutionListener(listener: FlatWizardExecutionListener) { - flatWizardExecutionListeners.add(listener) - } - - fun unregisterFlatWizardExecutionListener(listener: FlatWizardExecutionListener) { - flatWizardExecutionListeners.remove(listener) - } - - fun registerCameraCaptureListener(listener: CameraCaptureListener): Boolean { - return cameraCaptureListeners.add(listener) - } - - fun unregisterCameraCaptureListener(listener: CameraCaptureListener): Boolean { - return cameraCaptureListeners.remove(listener) - } - - override fun execute(stepExecution: StepExecution): StepResult { - if (stopped) return StepResult.FINISHED - - val delta = exposureMax.toMillis() - exposureMin.toMillis() - - if (delta < 10) { - LOG.warn("Failed to find an optimal exposure time. exposureMin={}, exposureMax={}", exposureMin, exposureMax) - flatWizardExecutionListeners.forEach { it.onFlatFailed(this) } - return StepResult.FINISHED - } - - exposureTime = (exposureMax + exposureMin).dividedBy(2L) - - val cameraExposureStep = CameraExposureStep( - camera, request.captureRequest.copy( - exposureTime = exposureTime, exposureAmount = 1, - autoSave = false, autoSubFolderMode = AutoSubFolderMode.OFF, - ) - ) - - var saved: Pair? = null - - val listener = object : CameraCaptureListener { - - override fun onExposureFinished(step: CameraExposureStep, stepExecution: StepExecution, image: ImageRepresentation?, savedPath: Path) { - saved = image to savedPath - } - } - - this.cameraExposureStep.set(cameraExposureStep) - cameraCaptureListeners.forEach(cameraExposureStep::registerCameraCaptureListener) - cameraExposureStep.registerCameraCaptureListener(listener) - cameraExposureStep.executeSingle(stepExecution) - - if (!stopped && saved != null) { - val (imageRepresentation, savedPath) = saved!! - - image = if (imageRepresentation != null) image?.load(imageRepresentation) ?: Image.open(imageRepresentation, false) - else savedPath.fits().use { image?.load(it) ?: Image.open(it, false) } - - imageBucket?.put(savedPath, image!!) - - val statistics = STATISTICS.compute(image!!) - LOG.info("flat frame captured. duration={}, statistics={}", exposureTime, statistics) - - if (statistics.mean in meanRange) { - LOG.info("Found an optimal exposure time. exposure={}, path={}", exposureTime, savedPath) - flatWizardExecutionListeners.forEach { it.onFlatCaptured(this, savedPath, exposureTime) } - } else if (statistics.mean < meanRange.start) { - exposureMin = cameraExposureStep.exposureTime - return StepResult.CONTINUABLE - } else { - exposureMax = cameraExposureStep.exposureTime - return StepResult.CONTINUABLE - } - } - - return StepResult.FINISHED - } - - override fun stop(mayInterruptIfRunning: Boolean) { - stopped = true - cameraExposureStep.getAndSet(null)?.stop(mayInterruptIfRunning) - } - - companion object { - - @JvmStatic private val STATISTICS = Statistics(noMedian = true, noDeviation = true) - @JvmStatic private val LOG = LoggerFactory.getLogger(FlatWizardStep::class.java) - } -} diff --git a/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt new file mode 100644 index 000000000..865bed152 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/wizard/flat/FlatWizardTask.kt @@ -0,0 +1,128 @@ +package nebulosa.api.wizard.flat + +import nebulosa.api.cameras.AutoSubFolderMode +import nebulosa.api.cameras.CameraCaptureEvent +import nebulosa.api.cameras.CameraCaptureState +import nebulosa.api.cameras.CameraCaptureTask +import nebulosa.api.messages.MessageEvent +import nebulosa.api.tasks.AbstractTask +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.fits.fits +import nebulosa.image.Image +import nebulosa.image.algorithms.computation.Statistics +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.camera.CameraEvent +import nebulosa.indi.device.camera.FrameType +import nebulosa.log.loggerFor +import java.nio.file.Path +import java.time.Duration + +data class FlatWizardTask( + @JvmField val camera: Camera, + @JvmField val request: FlatWizardRequest, +) : AbstractTask() { + + private val meanTarget = request.meanTarget / 65535f + private val meanRange = (meanTarget * request.meanTolerance / 100f).let { (meanTarget - it)..(meanTarget + it) } + + @Volatile private var cameraCaptureTask: CameraCaptureTask? = null + @Volatile private var exposureMin = request.exposureMin + @Volatile private var exposureMax = request.exposureMax + @Volatile private var exposureTime = Duration.ZERO + + @Volatile private var state = FlatWizardState.IDLE + @Volatile private var capture: CameraCaptureEvent? = null + @Volatile private var savedPath: Path? = null + + fun handleCameraEvent(event: CameraEvent) { + cameraCaptureTask?.handleCameraEvent(event) + } + + override fun canUseAsLastEvent(event: MessageEvent) = event is FlatWizardEvent + + override fun execute(cancellationToken: CancellationToken) { + while (!cancellationToken.isDone) { + val delta = exposureMax.toMillis() - exposureMin.toMillis() + + if (delta < 10) { + LOG.warn("Failed to find an optimal exposure time. exposureMin={}, exposureMax={}", exposureMin, exposureMax) + state = FlatWizardState.FAILED + break + } + + exposureTime = (exposureMax + exposureMin).dividedBy(2L) + + LOG.info("Flat Wizard started. camera={}, request={}, exposureTime={}", camera, request, exposureTime) + + val cameraRequest = request.capture.copy( + exposureTime = exposureTime, frameType = FrameType.FLAT, + autoSave = false, autoSubFolderMode = AutoSubFolderMode.OFF, + ) + + state = FlatWizardState.EXPOSURING + + CameraCaptureTask(camera, cameraRequest).use { + cameraCaptureTask = it + + it.subscribe { event -> + capture = event + + if (event.state == CameraCaptureState.EXPOSURE_FINISHED) { + savedPath = event.savePath!! + onNext(event) + } + + sendEvent() + } + + it.execute(cancellationToken) + } + + if (cancellationToken.isDone) { + state = FlatWizardState.IDLE + break + } else if (savedPath == null) { + state = FlatWizardState.FAILED + break + } + + val image = savedPath!!.fits().use { Image.open(it, false) } + + val statistics = STATISTICS.compute(image) + LOG.info("flat frame captured. exposureTime={}, statistics={}", exposureTime, statistics) + + if (statistics.mean in meanRange) { + state = FlatWizardState.CAPTURED + LOG.info("found an optimal exposure time. exposureTime={}, path={}", exposureTime, savedPath) + break + } else if (statistics.mean < meanRange.start) { + savedPath = null + exposureMin = exposureTime + LOG.info("captured frame is below mean range. exposureTime={}, path={}", exposureTime, savedPath) + } else { + savedPath = null + exposureMax = exposureTime + LOG.info("captured frame is above mean range. exposureTime={}, path={}", exposureTime, savedPath) + } + } + + if (state != FlatWizardState.FAILED && cancellationToken.isDone) { + state = FlatWizardState.IDLE + } + + sendEvent() + + LOG.info("Flat Wizard finished. camera={}, request={}, exposureTime={}", camera, request, exposureTime) + } + + @Suppress("NOTHING_TO_INLINE") + private inline fun sendEvent() { + onNext(FlatWizardEvent(state, exposureTime, capture, savedPath)) + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + @JvmStatic private val STATISTICS = Statistics(noMedian = true, noDeviation = true) + } +} diff --git a/api/src/test/kotlin/APITest.kt b/api/src/test/kotlin/APITest.kt new file mode 100644 index 000000000..673abd1cb --- /dev/null +++ b/api/src/test/kotlin/APITest.kt @@ -0,0 +1,91 @@ +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.kotlinModule +import io.kotest.core.annotation.EnabledIf +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.booleans.shouldBeTrue +import nebulosa.api.cameras.CameraStartCaptureRequest +import nebulosa.common.json.PathSerializer +import nebulosa.test.NonGitHubOnlyCondition +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.logging.HttpLoggingInterceptor +import java.nio.file.Path +import java.time.Duration + +@EnabledIf(NonGitHubOnlyCondition::class) +class APITest : StringSpec() { + + init { + "Connect" { put("connection?host=localhost&port=7624") } + "Cameras" { get("cameras") } + "Camera Connect" { put("cameras/$CAMERA_NAME/connect") } + "Camera" { get("cameras/$CAMERA_NAME") } + "Camera Capture Start" { putJson("cameras/$CAMERA_NAME/capture/start", CAMERA_START_CAPTURE_REQUEST) } + "Camera Capture Stop" { put("cameras/$CAMERA_NAME/capture/abort") } + "Camera Disconnect" { put("cameras/$CAMERA_NAME/disconnect") } + "Mounts" { get("mounts") } + "Mount Connect" { put("mounts/$MOUNT_NAME/connect") } + "Mount" { get("mounts/$MOUNT_NAME") } + "Mount Telescope Control Start" { put("mounts/$MOUNT_NAME/remote-control/start?type=LX200&host=0.0.0.0&port=10001") } + "Mount Telescope Control List" { get("mounts/$MOUNT_NAME/remote-control") } + "Mount Telescope Control Stop" { put("mounts/$MOUNT_NAME/remote-control/stop?type=LX200") } + "Mount Disconnect" { put("mounts/$MOUNT_NAME/disconnect") } + "Disconnect" { delete("connection") } + } + + companion object { + + private const val BASE_URL = "http://localhost:7000" + private const val CAMERA_NAME = "CCD Simulator" + private const val MOUNT_NAME = "Telescope Simulator" + + @JvmStatic private val EXPOSURE_TIME = Duration.ofSeconds(5) + @JvmStatic private val CAPTURES_PATH = Path.of("/home/tiagohm/Git/nebulosa/data/captures") + @JvmStatic private val CAMERA_START_CAPTURE_REQUEST = + CameraStartCaptureRequest(exposureTime = EXPOSURE_TIME, width = 1280, height = 1024, frameFormat = "INDI_MONO", savePath = CAPTURES_PATH) + .copy(exposureAmount = 2) + + @JvmStatic private val CLIENT = OkHttpClient.Builder() + .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + .build() + + @JvmStatic private val KOTLIN_MODULE = kotlinModule().addSerializer(PathSerializer) + + @JvmStatic private val OBJECT_MAPPER = ObjectMapper() + .registerModule(JavaTimeModule()) + .registerModule(KOTLIN_MODULE) + + @JvmStatic private val APPLICATION_JSON = "application/json".toMediaType() + @JvmStatic private val EMPTY_BODY = ByteArray(0).toRequestBody(APPLICATION_JSON) + + @JvmStatic + private fun get(path: String) { + val request = Request.Builder().get().url("$BASE_URL/$path").build() + CLIENT.newCall(request).execute().use { it.isSuccessful.shouldBeTrue() } + } + + @JvmStatic + private fun put(path: String, body: RequestBody = EMPTY_BODY) { + val request = Request.Builder().put(body).url("$BASE_URL/$path").build() + CLIENT.newCall(request).execute().use { it.isSuccessful.shouldBeTrue() } + } + + @JvmStatic + private fun putJson(path: String, data: Any) { + val bytes = OBJECT_MAPPER.writeValueAsBytes(data) + val body = bytes.toRequestBody(APPLICATION_JSON) + val request = Request.Builder().put(body).url("$BASE_URL/$path").build() + CLIENT.newCall(request).execute().use { it.isSuccessful.shouldBeTrue() } + } + + @JvmStatic + private fun delete(path: String) { + val request = Request.Builder().delete().url("$BASE_URL/$path").build() + CLIENT.newCall(request).execute().use { it.isSuccessful.shouldBeTrue() } + } + } +} diff --git a/api/src/test/kotlin/AstrobinEquipmentGenerator.kt b/api/src/test/kotlin/AstrobinEquipmentGenerator.kt index 1da8f76c9..d898f067a 100644 --- a/api/src/test/kotlin/AstrobinEquipmentGenerator.kt +++ b/api/src/test/kotlin/AstrobinEquipmentGenerator.kt @@ -3,6 +3,7 @@ import nebulosa.astrobin.api.AstrobinService import nebulosa.astrobin.api.Camera import nebulosa.astrobin.api.Sensor import nebulosa.astrobin.api.Telescope +import nebulosa.log.loggerFor import java.nio.file.Path import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors @@ -11,13 +12,14 @@ import kotlin.math.max object AstrobinEquipmentGenerator { - @JvmStatic private val SENSORS = ConcurrentHashMap() - @JvmStatic private val CAMERAS = ConcurrentHashMap() - @JvmStatic private val TELESCOPES = ConcurrentHashMap() + @JvmStatic private val SENSORS = ConcurrentHashMap(1024) + @JvmStatic private val CAMERAS = ConcurrentHashMap(4092) + @JvmStatic private val TELESCOPES = ConcurrentHashMap(4092) @JvmStatic private val OBJECT_MAPPER = ObjectMapper() @JvmStatic private val CAMERA_PATH = Path.of("data", "astrobin", "cameras.json") @JvmStatic private val TELESCOPE_PATH = Path.of("data", "astrobin", "telescopes.json") @JvmStatic private val EXECUTOR_SERVICE = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()) + @JvmStatic private val LOG = loggerFor() data class CameraEquipment( val id: Long, val name: String, val sensor: String, @@ -33,51 +35,53 @@ object AstrobinEquipmentGenerator { fun main(args: Array) { val astrobin = AstrobinService() - val task1 = EXECUTOR_SERVICE.submit { - for (i in 1..99) { - val sensors = astrobin.sensors(i).execute().body() - ?.takeIf { it.results.isNotEmpty() }?.results ?: break - - println("SENSOR: $i") - - sensors.forEach { SENSORS[it.id] = it } + val a = EXECUTOR_SERVICE.submit { + for (page in 1..99) { + astrobin.sensors(page).execute().body() + ?.takeIf { it.results.isNotEmpty() } + ?.results + ?.forEach { SENSORS[it.id] = it } + ?.also { LOG.info("sensor: $page") } + ?: break } } - val task2 = EXECUTOR_SERVICE.submit { - for (i in 1..99) { - val cameras = astrobin.cameras(i).execute().body() - ?.takeIf { it.results.isNotEmpty() }?.results ?: break - - println("CAMERA: $i") + val b = EXECUTOR_SERVICE.submit { + for (page in 1..99) { + astrobin.cameras(page).execute().body() + ?.takeIf { it.results.isNotEmpty() } + ?.results + ?.forEach { CAMERAS[it.id] = it } + ?.also { LOG.info("camera: $page") } + ?: break - cameras.forEach { CAMERAS[it.id] = it } } } - val task3 = EXECUTOR_SERVICE.submit { - for (i in 1..99) { - val telescopes = astrobin.telescopes(i).execute().body() - ?.takeIf { it.results.isNotEmpty() }?.results ?: break - - println("TELESCOPE: $i") - - telescopes.forEach { TELESCOPES[it.id] = it } + val c = EXECUTOR_SERVICE.submit { + for (page in 1..99) { + astrobin.telescopes(page).execute().body() + ?.takeIf { it.results.isNotEmpty() } + ?.results + ?.forEach { TELESCOPES[it.id] = it } + ?.also { LOG.info("telescope: $page") } + ?: break } } - task1.get() - task2.get() - task3.get() + a.get() + b.get() + c.get() - println("CAMERA SIZE: ${CAMERAS.size}") - println("SENSOR SIZE: ${SENSORS.size}") - println("TELESCOPE SIZE: ${TELESCOPES.size}") + LOG.info("cameras: ${CAMERAS.size}") + LOG.info("sensors: ${SENSORS.size}") + LOG.info("telescopes: ${TELESCOPES.size}") val output = HashSet(max(CAMERAS.size, TELESCOPES.size)) for ((key, value) in CAMERAS) { - val sensor = SENSORS[value.sensor] ?: continue + if (!value.isValid) continue + val sensor = SENSORS[value.sensor]?.takeIf { it.isValid } ?: continue val name = "%s %s".format(value.brandName, value.name).replace("(color)", "").replace("(mono)", "").trim() val sensorName = "%s %s".format(sensor.brandName, sensor.name) @@ -88,8 +92,9 @@ object AstrobinEquipmentGenerator { output.clear() for ((key, value) in TELESCOPES) { + if (!value.isValid) continue val name = "%s %s".format(value.brandName, value.name) - output.add(TelescopeEquipment(key, name, value.aperture, value.maxFocalLength)) + output.add(TelescopeEquipment(key, name, value.aperture, max(value.minFocalLength, value.maxFocalLength))) } TELESCOPE_PATH.outputStream().use { OBJECT_MAPPER.writeValue(it, output) } diff --git a/build.gradle.kts b/build.gradle.kts index 9ae1bb405..686ead046 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,10 +6,10 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile buildscript { dependencies { - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.0-Beta5") - classpath("org.jetbrains.kotlin:kotlin-allopen:2.0.0-Beta5") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.0") + classpath("org.jetbrains.kotlin:kotlin-allopen:2.0.0") classpath("com.adarshr:gradle-test-logger-plugin:4.0.0") - classpath("io.objectbox:objectbox-gradle-plugin:3.8.0") + classpath("io.objectbox:objectbox-gradle-plugin:4.0.0") } repositories { diff --git a/data/.gitignore b/data/.gitignore index 6642278fe..b39285bd2 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -1,3 +1,4 @@ simbad/ astrobin/ test/ +captures/ diff --git a/desktop/.npmrc b/desktop/.npmrc index f5357d5e2..3e8dbf785 100644 --- a/desktop/.npmrc +++ b/desktop/.npmrc @@ -1,2 +1,6 @@ save=true save-exact=true +audit=false +fund=false +progress=false +update-notifier=false diff --git a/desktop/README.md b/desktop/README.md index e48a26619..b6880a99a 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -22,6 +22,10 @@ The complete integrated solution for all of your astronomical imaging needs. ![](filter-wheel.png) +## Rotator + +![](rotator.png) + ## Guider ![](guider.png) diff --git a/desktop/alignment.png b/desktop/alignment.png index 49564e864..d6007af00 100644 Binary files a/desktop/alignment.png and b/desktop/alignment.png differ diff --git a/desktop/app/main.ts b/desktop/app/main.ts index 04d3ac859..1b854f570 100644 --- a/desktop/app/main.ts +++ b/desktop/app/main.ts @@ -1,9 +1,10 @@ import { Client } from '@stomp/stompjs' import { BrowserWindow, Menu, Notification, Point, Size, app, dialog, ipcMain, screen, shell } from 'electron' +import * as Store from 'electron-store' import * as fs from 'fs' import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process' -import * as path from 'path' - +import { join } from 'path' +import { parseArgs } from 'util' import { WebSocket } from 'ws' import { MessageEvent } from '../src/shared/types/api.types' import { CloseWindow, InternalEventType, JsonFile, NotificationEvent, OpenDirectory, OpenFile, OpenWindow } from '../src/shared/types/app.types' @@ -12,73 +13,69 @@ Object.assign(global, { WebSocket }) app.commandLine.appendSwitch('disable-http-cache') -const browserWindows = new Map() -const modalWindows = new Map void }>() -let api: ChildProcessWithoutNullStreams | null = null -let apiPort = 7000 -let webSocket: Client - -const args = process.argv.slice(1) -const serve = args.some(e => e === '--serve') -const appDir = path.join(app.getPath('appData'), 'nebulosa') -const appIcon = path.join(__dirname, serve ? `../src/assets/icons/nebulosa.png` : `assets/icons/nebulosa.png`) - -if (!fs.existsSync(appDir)) { - fs.mkdirSync(appDir) +interface CreatedWindow { + options: OpenWindow + window: BrowserWindow } -class SimpleDB { +interface CreatedModalWindow extends CreatedWindow { + resolve: (data: any) => void +} - private readonly data: Record +interface WindowPreference { + [key: `window.${string}.position`]: Point + [key: `window.${string}.size`]: Size +} - constructor(private path: fs.PathLike) { - try { - if (fs.existsSync(path)) { - const text = fs.readFileSync(path).toString('utf-8') - this.data = text.length > 0 ? JSON.parse(text) : {} - } else { - this.data = {} - } - } catch (e) { - this.data = {} - console.error(e) +const browserWindows = new Map() +const modalWindows = new Map() +let apiProcess: ChildProcessWithoutNullStreams | null = null +let webSocket: Client +let started = false + +const parsed = parseArgs({ + args: process.argv.slice(1), + allowPositionals: true, + options: { + 'serve': { + type: 'boolean' + }, + 'mode': { + type: 'string' + }, + 'host': { + type: 'string' + }, + 'port': { + type: 'string' } - } + }, +}) - get(key: string) { - return this.data[key] as T | undefined - } +const serve = parsed.values.serve ?? false +const apiMode = !serve && parsed.values.mode === 'api' +const uiMode = !serve && parsed.values.mode === 'ui' - set(key: string, value: any) { - if (value === undefined || value === null) delete this.data[key] - else this.data[key] = value +let apiHost = serve ? 'localhost' : parsed.values.host || 'localhost' +let apiPort = serve ? 7000 : parseInt(parsed.values.port || '0') - try { - this.save() - } catch (e) { - console.error(e) - } - } +const appIcon = join(__dirname, serve ? `../src/assets/icons/nebulosa.png` : `assets/icons/nebulosa.png`) +const store = new Store({ name: 'nebulosa' }) - save() { - fs.writeFileSync(this.path, JSON.stringify(this.data)) - } -} - -const database = new SimpleDB(path.join(appDir, 'nebulosa.data.json')) +process.on('beforeExit', () => apiProcess?.kill()) function isNotificationEvent(event: MessageEvent): event is NotificationEvent { return event.eventName === 'NOTIFICATION.SENT' } function createMainWindow() { - browserWindows.get('splash')?.close() + browserWindows.get('splash')?.window?.close() browserWindows.delete('splash') createWindow({ id: 'home', path: 'home', data: undefined }) webSocket = new Client({ - brokerURL: `ws://localhost:${apiPort}/ws`, + brokerURL: `ws://${apiHost}:${apiPort}/ws`, onConnect: () => { webSocket.subscribe('NEBULOSA.EVENT', message => { const event = JSON.parse(message.body) as MessageEvent @@ -109,7 +106,9 @@ function createMainWindow() { } function createWindow(options: OpenWindow, parent?: BrowserWindow) { - let window = browserWindows.get(options.id) + const createdWindow = browserWindows.get(options.id) + + let window = createdWindow?.window if (window && !options.modal) { if (options.data) { @@ -132,7 +131,8 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { } } - const width = options.width ? Math.trunc(computeWidth(options.width)) : 320 + const minWidth = options.minWidth ?? 0 + const width = Math.max(minWidth, options.width ? Math.trunc(computeWidth(options.width)) : 320) function computeHeight(value: number | string) { if (typeof value === 'number') { @@ -146,21 +146,21 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { } } - const height = options.height ? Math.trunc(computeHeight(options.height)) : 416 + const minHeight = options.minHeight ?? 0 + const height = Math.max(minHeight, options.height ? Math.trunc(computeHeight(options.height)) : 416) const id = options.id const resizable = options.resizable ?? false - const autoResizable = options.autoResizable !== false const modal = options.modal ?? false const icon = options.icon ?? 'nebulosa' const data = encodeURIComponent(JSON.stringify(options.data || {})) - const savedPos = !modal ? database.get(`window.${id}.position`) : undefined - const savedSize = !modal && resizable ? database.get(`window.${id}.size`) : undefined + const savedPosition = !modal ? store.get(`window.${id}.position`) : undefined + const savedSize = !modal && resizable ? store.get(`window.${id}.size`) : undefined - if (savedPos) { - savedPos.x = Math.max(0, Math.min(savedPos.x, screenSize.width)) - savedPos.y = Math.max(0, Math.min(savedPos.y, screenSize.height)) + if (savedPosition) { + savedPosition.x = Math.max(0, Math.min(savedPosition.x, screenSize.width)) + savedPosition.y = Math.max(0, Math.min(savedPosition.y, screenSize.height)) } if (savedSize) { @@ -173,32 +173,30 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { frame: false, modal, parent, width: savedSize?.width || width, height: savedSize?.height || height, - x: savedPos?.x ?? undefined, - y: savedPos?.y ?? undefined, + minWidth, minHeight, + x: savedPosition?.x ?? undefined, + y: savedPosition?.y ?? undefined, resizable: serve || resizable, autoHideMenuBar: true, - icon: path.join(__dirname, serve ? `../src/assets/icons/${icon}.png` : `assets/icons/${icon}.png`), + icon: join(__dirname, serve ? `../src/assets/icons/${icon}.png` : `assets/icons/${icon}.png`), webPreferences: { nodeIntegration: true, allowRunningInsecureContent: serve, contextIsolation: false, - additionalArguments: [`--port=${apiPort}`, `--options=${Buffer.from(JSON.stringify(options)).toString('base64')}`], - preload: path.join(__dirname, 'preload.js'), + additionalArguments: [`--host=${apiHost}`, `--port=${apiPort}`, `--options=${Buffer.from(JSON.stringify(options)).toString('base64')}`], + preload: join(__dirname, 'preload.js'), devTools: serve, }, }) - if (!savedPos) { + if (!savedPosition) { window.center() } if (serve) { - const debug = require('electron-debug') - debug({ showDevTools: false }) - window.loadURL(`http://localhost:4200/${options.path}?data=${data}`) } else { - const url = new URL(path.join('file:', __dirname, `index.html`) + `#/${options.path}?data=${data}`) + const url = new URL(join('file:', __dirname, `index.html`) + `#/${options.path}?data=${data}`) window.loadURL(url.href) } @@ -208,39 +206,41 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { }) window.on('close', () => { + console.info('window closed:', id, window.id) + const homeWindow = browserWindows.get('home') if (!modal) { const [x, y] = window!.getPosition() const [width, height] = window!.getSize() - database.set(`window.${id}.position`, { x, y }) + store.set(`window.${id}.position`, { x, y }) if (resizable) { - database.set(`window.${id}.size`, { width, height }) + store.set(`window.${id}.size`, { width, height }) } } - if (window === homeWindow) { + if (window === homeWindow?.window || id === homeWindow?.options?.id) { browserWindows.delete('home') for (const [_, value] of browserWindows) { - value.close() + value.window.close() } browserWindows.clear() - api?.kill() + apiProcess?.kill() } else { for (const [key, value] of browserWindows) { - if (value === window) { + if (value.window === window || value.options.id === id) { browserWindows.delete(key) break } } for (const [key, value] of modalWindows) { - if (value.window === window) { + if (value.window === window || value.options.id === id) { modalWindows.delete(key) break } @@ -248,31 +248,32 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { } }) - browserWindows.set(id, window) + browserWindows.set(id, { window, options }) + + console.info('window created:', id, window.id) return window } function createSplashScreen() { - let splashWindow = browserWindows.get('splash') - - if (!serve && !splashWindow) { - splashWindow = new BrowserWindow({ + if (!serve && !browserWindows.has('splash')) { + const window = new BrowserWindow({ width: 512, height: 512, transparent: true, frame: false, alwaysOnTop: true, show: false, + resizable: false, }) - const url = new URL(path.join('file:', __dirname, 'assets', 'images', 'splash.png')) - splashWindow.loadURL(url.href) + const url = new URL(join('file:', __dirname, 'assets', 'images', 'splash.png')) + window.loadURL(url.href) - splashWindow.show() - splashWindow.center() + window.show() + window.center() - browserWindows.set('splash', splashWindow) + browserWindows.set('splash', { window, options: { id: 'splash', path: '', data: undefined } }) } } @@ -286,24 +287,43 @@ function showNotification(event: NotificationEvent) { } } -function findWindowById(id: number) { - for (const [key, window] of browserWindows) if (window.id === id) return { window, key } - for (const [key, window] of modalWindows) if (window.window.id === id) return { window: window.window, key } +function findWindowById(id: number | string) { + for (const [_, window] of browserWindows) if (window.window.id === id || window.options.id === id) return window + for (const [_, window] of modalWindows) if (window.window.id === id || window.options.id === id) return window return undefined } +function createApiProcess() { + const apiJar = join(process.resourcesPath, 'api.jar') + const apiProcess = spawn('java', ['-jar', apiJar, `--server.port=${apiPort}`]) + + apiProcess.on('close', (code) => { + console.warn(`server process exited with code ${code}`) + process.exit(code || 0) + }) + + return apiProcess +} + function startApp() { - if (api === null) { - if (serve) { - createMainWindow() - } else { + if (!started) { + started = true + + if (apiMode) { + apiProcess = createApiProcess() + } else if (uiMode) { createSplashScreen() - const apiJar = path.join(process.resourcesPath, 'api.jar') + console.info(`server is at ${apiHost}@${apiPort}`) - api = spawn('java', ['-jar', apiJar]) + createMainWindow() + } else if (serve) { + createMainWindow() + } else { + createSplashScreen() + apiProcess = createApiProcess() - api.stdout.on('data', (data) => { + apiProcess.stdout.on('data', (data) => { const text = `${data}` if (text) { @@ -312,17 +332,12 @@ function startApp() { if (match) { apiPort = parseInt(match[1]) - api!.stdout.removeAllListeners('data') - console.info(`server is started at port: ${apiPort}`) + apiProcess!.stdout.removeAllListeners('data') + console.info(`server was started at ${apiHost}@${apiPort}`) createMainWindow() } } }) - - api.on('close', (code) => { - console.warn(`server process exited with code ${code}`) - process.exit(code || 0) - }) } } } @@ -335,7 +350,7 @@ try { app.on('ready', () => setTimeout(startApp, 400)) app.on('window-all-closed', () => { - api?.kill() + apiProcess?.kill() if (process.platform !== 'darwin') { app.quit() @@ -350,14 +365,14 @@ try { } }) - ipcMain.handle('WINDOW.OPEN', async (event, data: OpenWindow) => { - if (data.modal) { + ipcMain.handle('WINDOW.OPEN', async (event, options: OpenWindow) => { + if (options.modal) { const parent = findWindowById(event.sender.id) - const window = createWindow(data, parent?.window) + const window = createWindow(options, parent?.window) const promise = new Promise((resolve) => { - modalWindows.set(data.id, { - window, resolve: (value) => { + modalWindows.set(options.id, { + window, options, resolve: (value) => { window.close() resolve(value) } @@ -367,13 +382,13 @@ try { return promise } - const isNew = !browserWindows.has(data.id) + const isNew = !browserWindows.has(options.id) - const window = createWindow(data) + const window = createWindow(options) - if (data.bringToFront) { + if (options.bringToFront) { window.show() - } else if (data.requestFocus) { + } else if (options.requestFocus) { window.focus() } @@ -390,7 +405,6 @@ try { ipcMain.handle('FILE.OPEN', async (event, data?: OpenFile) => { const ownerWindow = findWindowById(event.sender.id) - const value = await dialog.showOpenDialog(ownerWindow!.window, { filters: data?.filters, properties: ['openFile'], @@ -466,31 +480,53 @@ try { ipcMain.handle('WINDOW.MAXIMIZE', (event) => { const window = findWindowById(event.sender.id)?.window - if (window?.isMaximized()) window.unmaximize() - else window?.maximize() + if (!window) return false - return window?.isMaximized() ?? false + if (window.isMaximized()) { + window.unmaximize() + return false + } else { + window.maximize() + return true + } }) ipcMain.handle('WINDOW.RESIZE', (event, data: number) => { - const window = findWindowById(event.sender.id)?.window + const createdWindow = findWindowById(event.sender.id) - if (!window || (!serve && window.isResizable())) return false + if (!createdWindow) return false - const size = window.getContentSize() + const { window, options } = createdWindow + + if (!window || options.resizable || options.autoResizable === false) return false + + const [width] = window.getSize() const maxHeight = screen.getPrimaryDisplay().workAreaSize.height - const height = Math.max(0, Math.min(data, maxHeight)) - window.setContentSize(size[0], height) - console.info('window auto resized:', size[0], height) + const height = Math.max(options?.minHeight ?? 0, Math.min(data, maxHeight)) + + // https://github.com/electron/electron/issues/16711#issuecomment-1311824063 + window.setResizable(true) + window.setSize(width, height) + window.setResizable(serve) + + console.info('window auto resized:', options.id, width, height) return true }) + ipcMain.handle('WINDOW.FULLSCREEN', (event, enabled?: boolean) => { + const window = findWindowById(event.sender.id)?.window + if (!window) return false + const flag = enabled ?? !window.isFullScreen() + window.setFullScreen(flag) + return flag + }) + ipcMain.handle('WINDOW.CLOSE', (event, data: CloseWindow) => { if (data.id) { for (const [key, value] of browserWindows) { - if (key === data.id) { - value.close() + if (key === data.id || value.options.id === data.id) { + value.window.close() return true } } @@ -498,7 +534,7 @@ try { const window = findWindowById(event.sender.id) if (window) { - modalWindows.get(window.key)?.resolve(data.data) + modalWindows.get(window.options.id)?.resolve(data.data) window.window.close() return true } @@ -507,7 +543,7 @@ try { return false }) - const events: InternalEventType[] = ['WHEEL.RENAMED', 'LOCATION.CHANGED'] + const events: InternalEventType[] = ['WHEEL.RENAMED', 'LOCATION.CHANGED', 'CALIBRATION.CHANGED'] for (const item of events) { ipcMain.handle(item, (_, data) => { @@ -523,8 +559,8 @@ function sendToAllWindows(channel: string, data: any, home: boolean = true) { const homeWindow = browserWindows.get('home') for (const [_, window] of browserWindows) { - if (window !== homeWindow || home) { - window.webContents.send(channel, data) + if (window.window !== homeWindow?.window || home) { + window.window.webContents.send(channel, data) } } diff --git a/desktop/app/package-lock.json b/desktop/app/package-lock.json index 1dcf87531..7c90b681c 100644 --- a/desktop/app/package-lock.json +++ b/desktop/app/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "dependencies": { "@stomp/stompjs": "7.0.0", - "ws": "8.16.0" + "electron-store": "8.2.0", + "ws": "8.17.0" } }, "node_modules/@stomp/stompjs": { @@ -18,10 +19,308 @@ "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.0.0.tgz", "integrity": "sha512-fGdq4wPDnSV/KyOsjq4P+zLc8MFWC3lMmP5FBgLWKPJTYcuCbAIrnRGjB7q2jHZdYCOD5vxLuFoKIYLy5/u8Pw==" }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomically": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", + "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/conf": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/conf/-/conf-10.2.0.tgz", + "integrity": "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==", + "dependencies": { + "ajv": "^8.6.3", + "ajv-formats": "^2.1.1", + "atomically": "^1.7.0", + "debounce-fn": "^4.0.0", + "dot-prop": "^6.0.1", + "env-paths": "^2.2.1", + "json-schema-typed": "^7.0.3", + "onetime": "^5.1.2", + "pkg-up": "^3.1.0", + "semver": "^7.3.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debounce-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", + "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", + "dependencies": { + "mimic-fn": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron-store": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/electron-store/-/electron-store-8.2.0.tgz", + "integrity": "sha512-ukLL5Bevdil6oieAOXz3CMy+OgaItMiVBg701MNlG6W5RaC0AHN7rvlqTCmeb6O7jP0Qa1KKYTE0xV0xbhF4Hw==", + "dependencies": { + "conf": "^10.2.0", + "type-fest": "^2.17.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/json-schema-typed": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", + "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==" + }, + "node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mimic-fn": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", + "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", + "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", "engines": { "node": ">=10.0.0" }, @@ -37,6 +336,11 @@ "optional": true } } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } } diff --git a/desktop/app/package.json b/desktop/app/package.json index 84fca0719..257403d06 100644 --- a/desktop/app/package.json +++ b/desktop/app/package.json @@ -12,6 +12,7 @@ "private": true, "dependencies": { "@stomp/stompjs": "7.0.0", - "ws": "8.16.0" + "electron-store": "8.2.0", + "ws": "8.17.0" } } diff --git a/desktop/app/preload.js b/desktop/app/preload.js index 290851dd0..759efc74d 100644 --- a/desktop/app/preload.js +++ b/desktop/app/preload.js @@ -2,6 +2,7 @@ function argWith(name) { return process.argv.find(e => e.startsWith(`--${name}=`))?.split('=')?.[1] } +window.apiHost = argWith('host') window.apiPort = parseInt(argWith('port')) window.id = argWith('id') window.options = JSON.parse(Buffer.from(argWith('options'), 'base64').toString('utf-8')) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 0d6051795..23c11b95c 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -10,18 +10,18 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@angular/animations": "17.3.2", - "@angular/cdk": "17.3.2", - "@angular/common": "17.3.2", - "@angular/compiler": "17.3.2", - "@angular/core": "17.3.2", - "@angular/forms": "17.3.2", - "@angular/platform-browser": "17.3.2", - "@angular/platform-browser-dynamic": "17.3.2", - "@angular/router": "17.3.2", - "@fontsource/roboto": "5.0.12", + "@angular/animations": "17.3.9", + "@angular/cdk": "17.3.9", + "@angular/common": "17.3.9", + "@angular/compiler": "17.3.9", + "@angular/core": "17.3.9", + "@angular/forms": "17.3.9", + "@angular/platform-browser": "17.3.9", + "@angular/platform-browser-dynamic": "17.3.9", + "@angular/router": "17.3.9", + "@fontsource/roboto": "5.0.13", "@mdi/font": "7.4.47", - "chart.js": "4.4.2", + "chart.js": "4.4.3", "chartjs-plugin-zoom": "2.0.1", "hotkeys-js": "3.13.7", "interactjs": "1.10.27", @@ -30,32 +30,31 @@ "panzoom": "9.4.3", "primeflex": "3.3.1", "primeicons": "7.0.0", - "primeng": "17.12.0", + "primeng": "17.17.0", "rxjs": "7.8.1", "tslib": "2.6.2", "uuid": "9.0.1", - "zone.js": "0.14.4" + "zone.js": "0.14.6" }, "devDependencies": { - "@angular-builders/custom-webpack": "17.0.1", - "@angular-devkit/build-angular": "17.3.2", - "@angular/cli": "17.3.2", - "@angular/compiler-cli": "17.3.2", - "@angular/language-service": "17.3.2", - "@types/leaflet": "1.9.8", - "@types/node": "20.12.2", + "@angular-builders/custom-webpack": "17.0.2", + "@angular-devkit/build-angular": "17.3.7", + "@angular/cli": "17.3.7", + "@angular/compiler-cli": "17.3.9", + "@angular/language-service": "17.3.9", + "@types/leaflet": "1.9.12", + "@types/node": "20.12.12", "@types/uuid": "9.0.8", - "electron": "29.1.6", + "electron": "30.0.6", "electron-builder": "24.13.3", - "electron-debug": "3.2.0", "node-polyfill-webpack-plugin": "3.0.0", "npm-run-all": "4.1.5", "ts-node": "10.9.2", - "typescript": "5.3.3", + "typescript": "5.4.5", "wait-on": "7.2.0" }, "engines": { - "node": ">= 20.9.0" + "node": ">= 22.0.0" } }, "node_modules/@ampproject/remapping": { @@ -72,9 +71,9 @@ } }, "node_modules/@angular-builders/common": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@angular-builders/common/-/common-1.0.1.tgz", - "integrity": "sha512-qPgTjz3ISdGIY+vOIiUzpZRXwchdL/HEhCRzM2QKdqz/c5AB06X9wKhvXezabtzpYSq4lN9fliPYCntqimefFw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@angular-builders/common/-/common-1.0.2.tgz", + "integrity": "sha512-lUusRq6jN1It5LcUTLS6Q+AYAYGTo/EEN8hV0M6Ek9qXzweAouJaSEnwv7p04/pD7yJTl0YOCbN79u+wGm3x4g==", "dev": true, "dependencies": { "@angular-devkit/core": "^17.1.0", @@ -86,12 +85,12 @@ } }, "node_modules/@angular-builders/custom-webpack": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/@angular-builders/custom-webpack/-/custom-webpack-17.0.1.tgz", - "integrity": "sha512-wRmCy8B+/SPv10Ufy2WqDhU68UGxF6fPPGu2ZeBRqzh10axvdfyD20a4v8xITfAaraOJb/MA4qsUs0x96QQCCQ==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/@angular-builders/custom-webpack/-/custom-webpack-17.0.2.tgz", + "integrity": "sha512-K0jqdW5UdVIeKiZXO4nLiiiVt0g6PKJELdxgjsBGMtyRk+RLEY+pIp1061oy/Yf09nGYseZ7Mdx3XASYHQjNwA==", "dev": true, "dependencies": { - "@angular-builders/common": "1.0.1", + "@angular-builders/common": "1.0.2", "@angular-devkit/architect": ">=0.1700.0 < 0.1800.0", "@angular-devkit/build-angular": "^17.0.0", "@angular-devkit/core": "^17.0.0", @@ -106,12 +105,12 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1703.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.2.tgz", - "integrity": "sha512-fT5gSzwDHOyGv8zF97t8rjeoYSGSxXjWWstl3rN1nXdO0qgJ5m6Sv0fupON+HltdXDCBLRH+2khNpqx/Fh0Qww==", + "version": "0.1703.7", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.7.tgz", + "integrity": "sha512-SwXbdsZqEE3JtvujCLChAii+FA20d1931VDjDYffrGWdQEViTBAr4NKtDr/kOv8KkgiL3fhGibPnRNUHTeAMtg==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.3.2", + "@angular-devkit/core": "17.3.7", "rxjs": "7.8.1" }, "engines": { @@ -121,15 +120,15 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.3.2.tgz", - "integrity": "sha512-muPCUyL0uHvRkLH4NLWiccER6P2vCm/Q5DDvqyN4XOzzY3tAHHLrKrpvY87sgd2oNJ6Ci8x7GPNcfzR5KELCnw==", + "version": "17.3.7", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.3.7.tgz", + "integrity": "sha512-AsV80kiFMIPIhm3uzJgOHDj4u6JteUkZedPTKAFFFJC7CTat1luW5qx306vfF7wj62aMvUl5g9HFWaeLghTQGA==", "dev": true, "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1703.2", - "@angular-devkit/build-webpack": "0.1703.2", - "@angular-devkit/core": "17.3.2", + "@angular-devkit/architect": "0.1703.7", + "@angular-devkit/build-webpack": "0.1703.7", + "@angular-devkit/core": "17.3.7", "@babel/core": "7.24.0", "@babel/generator": "7.23.6", "@babel/helper-annotate-as-pure": "7.22.5", @@ -140,7 +139,7 @@ "@babel/preset-env": "7.24.0", "@babel/runtime": "7.24.0", "@discoveryjs/json-ext": "0.5.7", - "@ngtools/webpack": "17.3.2", + "@ngtools/webpack": "17.3.7", "@vitejs/plugin-basic-ssl": "1.1.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.18", @@ -181,8 +180,8 @@ "terser": "5.29.1", "tree-kill": "1.2.2", "tslib": "2.6.2", - "undici": "6.7.1", - "vite": "5.1.5", + "undici": "6.11.1", + "vite": "5.1.7", "watchpack": "2.4.0", "webpack": "5.90.3", "webpack-dev-middleware": "6.1.2", @@ -352,12 +351,12 @@ } }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1703.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1703.2.tgz", - "integrity": "sha512-w7rVFQcZK4iTCd/MLfQWIkDkwBOfAs++txNQyS9qYID8KvLs1V+oWYd2qDBRelRv1u3YtaCIS1pQx3GFKBC3OA==", + "version": "0.1703.7", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1703.7.tgz", + "integrity": "sha512-gpt2Ia5I1gmdp3hdbtB7tkZTba5qWmKeVhlCYswa/LvbceKmkjedoeNRAoyr1UKM9GeGqt6Xl1B2eHzCH+ykrg==", "dev": true, "dependencies": { - "@angular-devkit/architect": "0.1703.2", + "@angular-devkit/architect": "0.1703.7", "rxjs": "7.8.1" }, "engines": { @@ -371,9 +370,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.2.tgz", - "integrity": "sha512-1vxKo9+pdSwTOwqPDSYQh84gZYmCJo6OgR5+AZoGLGMZSeqvi9RG5RiUcOMLQYOnuYv0arlhlWxz0ZjyR8ApKw==", + "version": "17.3.7", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.7.tgz", + "integrity": "sha512-qpZ7BShyqS/Jqld36E7kL02cyb2pjn1Az1p9439SbP8nsvJgYlsyjwYK2Kmcn/Wi+TZGIKxkqxgBBw9vqGgeJw==", "dev": true, "dependencies": { "ajv": "8.12.0", @@ -398,12 +397,12 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.2.tgz", - "integrity": "sha512-AYO6oc6QpFGigc1KiDzEVT1CeLnwvnIedU5Q/U3JDZ/Yqmvgc09D64g9XXER2kg6tV7iEgLxiYnonIAQOHq7eA==", + "version": "17.3.7", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.7.tgz", + "integrity": "sha512-d7NKSwstdxYLYmPsbcYO3GOFNfXxXwOyHxSqDa1JNKoSzMdbLj4tvlCpfXw0ThNM7gioMx8aLBaaH1ac+yk06Q==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.3.2", + "@angular-devkit/core": "17.3.7", "jsonc-parser": "3.2.1", "magic-string": "0.30.8", "ora": "5.4.1", @@ -416,9 +415,9 @@ } }, "node_modules/@angular/animations": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-17.3.2.tgz", - "integrity": "sha512-9RplCRS3dS7I8UeMmnwVCAxEaixQCj98UkSqjErO+GX5KJwMsFPydh7HKWH0/yclidJe5my41psEiQkyEyGKww==", + "version": "17.3.9", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-17.3.9.tgz", + "integrity": "sha512-9fSFF9Y+pKZGgGEK3IlVy9msS7LRFpD1h2rJ80N6n1k51jiKcTgOcFPPYwLNJZ2fkp+qrOAMo3ez4WYQgVPoow==", "dependencies": { "tslib": "^2.3.0" }, @@ -426,13 +425,13 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/core": "17.3.2" + "@angular/core": "17.3.9" } }, "node_modules/@angular/cdk": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-17.3.2.tgz", - "integrity": "sha512-mC2U7aoIf7RSpGgIwVyfQEbaPDDX59plQt88KeTz15wjF8vosLt2DG0rZEoV8Mq14YS47J+jI76q/LJfd6/GCw==", + "version": "17.3.9", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-17.3.9.tgz", + "integrity": "sha512-N/7Is+FkIIql5UEL/I+PV6THw+yXNCCGGpwimf/yaNgT9y1fHAmBWhDY0oQqFjCuD+kXl9gQL0ONfsl5Nlnk+w==", "dependencies": { "tslib": "^2.3.0" }, @@ -446,15 +445,15 @@ } }, "node_modules/@angular/cli": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.3.2.tgz", - "integrity": "sha512-g6r4XZyGnh9P6GmWgaFh8RmR4L6UdQ408e3SpG3rjncuPRD57Ur8806GfCLPt6HIA9TARiKmaJ0EJ3RsIjag0g==", + "version": "17.3.7", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.3.7.tgz", + "integrity": "sha512-JgCav3sdRCoJHwLXxmF/EMzArYjwbqB+AGUW/xIR98oZET8QxCB985bOFUAm02SkAEUVcMJvjxec+WCaa60m/A==", "dev": true, "dependencies": { - "@angular-devkit/architect": "0.1703.2", - "@angular-devkit/core": "17.3.2", - "@angular-devkit/schematics": "17.3.2", - "@schematics/angular": "17.3.2", + "@angular-devkit/architect": "0.1703.7", + "@angular-devkit/core": "17.3.7", + "@angular-devkit/schematics": "17.3.7", + "@schematics/angular": "17.3.7", "@yarnpkg/lockfile": "1.1.0", "ansi-colors": "4.1.3", "ini": "4.1.2", @@ -480,9 +479,9 @@ } }, "node_modules/@angular/common": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-17.3.2.tgz", - "integrity": "sha512-7fo+hrQEzo+VX0fJAKK+P4YNeiEnpdMOAkyIdwweyAeUZYeFIs6TKtax3CiJAubnkIkhQ/52uxiusDhK3Wg/WQ==", + "version": "17.3.9", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-17.3.9.tgz", + "integrity": "sha512-tH1VfbAvNVaz6ZYa+q0DiKtbmUql1jK/3q/af74B8nVjKLHcXVWwxvBayqvrmlUt7FGANGkETIcCWrB44k47Ug==", "dependencies": { "tslib": "^2.3.0" }, @@ -490,14 +489,14 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/core": "17.3.2", + "@angular/core": "17.3.9", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-17.3.2.tgz", - "integrity": "sha512-+/l/FQpVsOPbxZzSKyqEra+yxoI/r8LlTRqshVACv10+DKMWJMHnDkVUrNxvWHutfn4RszpGMtbtHp3yM9rxcA==", + "version": "17.3.9", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-17.3.9.tgz", + "integrity": "sha512-2d4bPbNm7O2GanqCj5GFgPDnmjbAcsQM502Jnvcv7Aje82yecT69JoqAVRqGOfbbxwlJiPhi31D8DPdLaOz47Q==", "dependencies": { "tslib": "^2.3.0" }, @@ -505,7 +504,7 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/core": "17.3.2" + "@angular/core": "17.3.9" }, "peerDependenciesMeta": { "@angular/core": { @@ -514,9 +513,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.2.tgz", - "integrity": "sha512-PG81BrJjeF679tkafjt+t9VEBE1rPq39cdLoBTnPY7Q+E/thVoem5JTRG6hmnLmwEc0xxY6sfYpvx2BB5ywUSA==", + "version": "17.3.9", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.9.tgz", + "integrity": "sha512-J6aqoz5wqPWaurbZFUZ7iMUlzAJYXzntziJJbalm6ceXfUWEe2Vm67nGUROWCIFvO3kWXvkgYX4ubnqtod2AxA==", "dev": true, "dependencies": { "@babel/core": "7.23.9", @@ -537,7 +536,7 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/compiler": "17.3.2", + "@angular/compiler": "17.3.9", "typescript": ">=5.2 <5.5" } }, @@ -587,9 +586,9 @@ } }, "node_modules/@angular/core": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-17.3.2.tgz", - "integrity": "sha512-eylatBGaN8uihKomEcXkaSHmAea5bEqu1OXifEoVOJiJpJA9Dbt/VcLXkIRFnRGH2NWUT5W79vSoU9GRvPMk5w==", + "version": "17.3.9", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-17.3.9.tgz", + "integrity": "sha512-x+h5BQ6islvYWGVLTz1CEgNq1/5IYngQ+Inq/tWayM6jN7RPOCydCCbCw+uOZS7MgFebkP0gYTVm14y1MRFKSQ==", "dependencies": { "tslib": "^2.3.0" }, @@ -602,9 +601,9 @@ } }, "node_modules/@angular/forms": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.3.2.tgz", - "integrity": "sha512-sbHYjAEeEWW+02YDEKuuuTEUukm6AayQuHiAu37vACj/2q/2RWQar49IoRcSJfAwP2ckqRSK4mmLoDX4IG/KSg==", + "version": "17.3.9", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.3.9.tgz", + "integrity": "sha512-5b8OjK0kLghrdxkVWglgerHVp9D5WvXInXwo1KIyc2v/fGdTlyu/RFi0GLGvzq2y+7Z8TvtXWC82SB47vfx3TQ==", "dependencies": { "tslib": "^2.3.0" }, @@ -612,25 +611,25 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/common": "17.3.2", - "@angular/core": "17.3.2", - "@angular/platform-browser": "17.3.2", + "@angular/common": "17.3.9", + "@angular/core": "17.3.9", + "@angular/platform-browser": "17.3.9", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/language-service": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-17.3.2.tgz", - "integrity": "sha512-IYlPHPi6RIQB9BQFwCY7rKRymlb4KhEr2UmXEpxIcj1QqVlMchYBVg2+twZloRj3qj/YQ19y2xxyPcgQRWHLIA==", + "version": "17.3.9", + "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-17.3.9.tgz", + "integrity": "sha512-0Zs5KmU5sPrDYaoLKqIFPx76H5aIceYWXvIG7oRg32uhaJ0nBqSe1tiYQp5T4e1iaNjCz6hgHKWP7ocQz71aHw==", "dev": true, "engines": { "node": "^18.13.0 || >=20.9.0" } }, "node_modules/@angular/platform-browser": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-17.3.2.tgz", - "integrity": "sha512-rBVmpJ/uh+CTjYef3Nib1K+31GFbM4mZaw2R2PowKZLgWOT3MWXKy41i44NEyM8qY1dxESmzMzy4NuGfZol42Q==", + "version": "17.3.9", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-17.3.9.tgz", + "integrity": "sha512-vMwHO76rnkz7aV3KHKy23KUFAo/+b0+yHPa6AND5Lee8z5C1J/tA2PdetFAsghlQQsX61JeK4MFJV/f3dFm2dw==", "dependencies": { "tslib": "^2.3.0" }, @@ -638,9 +637,9 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/animations": "17.3.2", - "@angular/common": "17.3.2", - "@angular/core": "17.3.2" + "@angular/animations": "17.3.9", + "@angular/common": "17.3.9", + "@angular/core": "17.3.9" }, "peerDependenciesMeta": { "@angular/animations": { @@ -649,9 +648,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-17.3.2.tgz", - "integrity": "sha512-fcGo9yQ+t9VaG9zPgjQW5HIizbYOKj+9kVk9FPru+uJbYyvJUwEDgpD3aI0DUrQy/OvSf4NMzY/Ucgw1AUknQw==", + "version": "17.3.9", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-17.3.9.tgz", + "integrity": "sha512-Jmth4hFC4dZsWQRkxB++42sR1pfJUoQbErANrKQMgEPb8H4cLRdB1mAQ6f+OASPBM+FsxDxjXq2kepyLGtF2Vg==", "dependencies": { "tslib": "^2.3.0" }, @@ -659,16 +658,16 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/common": "17.3.2", - "@angular/compiler": "17.3.2", - "@angular/core": "17.3.2", - "@angular/platform-browser": "17.3.2" + "@angular/common": "17.3.9", + "@angular/compiler": "17.3.9", + "@angular/core": "17.3.9", + "@angular/platform-browser": "17.3.9" } }, "node_modules/@angular/router": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-17.3.2.tgz", - "integrity": "sha512-BJiaG7zldhe8FPsg3Xv1o2xsmWNMIuntubRiSt2NlSceAr/NEgHoARpZfAGKTaFSngl6jc407wHOmBBPPALECw==", + "version": "17.3.9", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-17.3.9.tgz", + "integrity": "sha512-0cRF5YBJoDbXGQsRs3wEG+DPvN4PlhEqTa0DkTr9QIDJRg5P1uiDlOclV+w3OxEMsLrmXGmhjauHaWQk07M4LA==", "dependencies": { "tslib": "^2.3.0" }, @@ -676,9 +675,9 @@ "node": "^18.13.0 || >=20.9.0" }, "peerDependencies": { - "@angular/common": "17.3.2", - "@angular/core": "17.3.2", - "@angular/platform-browser": "17.3.2", + "@angular/common": "17.3.9", + "@angular/core": "17.3.9", + "@angular/platform-browser": "17.3.9", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -696,9 +695,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.1.tgz", - "integrity": "sha512-Pc65opHDliVpRHuKfzI+gSA4zcgr65O4cl64fFJIWEEh8JoHIHh0Oez1Eo8Arz8zq/JhgKodQaxEwUPRtZylVA==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", + "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", "dev": true, "engines": { "node": ">=6.9.0" @@ -814,19 +813,19 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.1.tgz", - "integrity": "sha512-1yJa9dX9g//V6fDebXoEfEsxkZHk3Hcbm+zLhyu6qVgYFLvmTALTeV+jNU9e5RnYtioBrGEOdoI2joMSNQ/+aA==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.5.tgz", + "integrity": "sha512-uRc4Cv8UQWnE4NXlYTIIdM7wfFkOqlFztcC/gVXDKohKoVB3OyonfelUBaJzSwpBntZ2KYGF/9S7asCHsXwW6g==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", - "@babel/helper-member-expression-to-functions": "^7.23.0", + "@babel/helper-member-expression-to-functions": "^7.24.5", "@babel/helper-optimise-call-expression": "^7.22.5", "@babel/helper-replace-supers": "^7.24.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-split-export-declaration": "^7.24.5", "semver": "^6.3.1" }, "engines": { @@ -836,6 +835,18 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz", + "integrity": "sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -872,9 +883,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.1.tgz", - "integrity": "sha512-o7SDgTJuvx5vLKD6SFvkydkSMBvahDKGiNJzG22IZYXhiqoe9efY7zocICBgzHV4IRg5wdgl2nEL/tulKIEIbA==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz", + "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==", "dev": true, "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", @@ -922,12 +933,12 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz", - "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.5.tgz", + "integrity": "sha512-4owRteeihKWKamtqg4JmWSsEZU445xpFRXPEwp44HbgbxdWlUV1b4Agg4lkA806Lil5XM/e+FJyS0vj5T6vmcA==", "dev": true, "dependencies": { - "@babel/types": "^7.23.0" + "@babel/types": "^7.24.5" }, "engines": { "node": ">=6.9.0" @@ -946,16 +957,16 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.5.tgz", + "integrity": "sha512-9GxeY8c2d2mdQUP1Dye0ks3VDyIMS98kt/llQ2nUId8IsWqTF0l1LkSX0/uP7l7MCDrzXS009Hyhe2gzTiGW8A==", "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" + "@babel/helper-module-imports": "^7.24.3", + "@babel/helper-simple-access": "^7.24.5", + "@babel/helper-split-export-declaration": "^7.24.5", + "@babel/helper-validator-identifier": "^7.24.5" }, "engines": { "node": ">=6.9.0" @@ -964,6 +975,18 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-module-transforms/node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz", + "integrity": "sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-optimise-call-expression": { "version": "7.22.5", "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", @@ -977,9 +1000,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz", - "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.5.tgz", + "integrity": "sha512-xjNLDopRzW2o6ba0gKbkZq5YWEBaK3PCyTOY1K2P/O07LGMhMqlMXPxwN4S5/RhWuCobT8z0jrlKGlYmeR1OhQ==", "dev": true, "engines": { "node": ">=6.9.0" @@ -1020,12 +1043,12 @@ } }, "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.5.tgz", + "integrity": "sha512-uH3Hmf5q5n7n8mz7arjUlDOCbttY/DW4DYhE6FUsjKJ/oYC1kQQUvwEQWxRwUpX9qQKRXeqLwWxrqilMrf32sQ==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.5" }, "engines": { "node": ">=6.9.0" @@ -1065,9 +1088,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz", + "integrity": "sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==", "dev": true, "engines": { "node": ">=6.9.0" @@ -1083,40 +1106,40 @@ } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz", - "integrity": "sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.24.5.tgz", + "integrity": "sha512-/xxzuNvgRl4/HLNKvnFwdhdgN3cpLxgLROeLDl83Yx0AJ1SGvq1ak0OszTOjDfiB8Vx03eJbeDWh9r+jCCWttw==", "dev": true, "dependencies": { - "@babel/helper-function-name": "^7.22.5", - "@babel/template": "^7.22.15", - "@babel/types": "^7.22.19" + "@babel/helper-function-name": "^7.23.0", + "@babel/template": "^7.24.0", + "@babel/types": "^7.24.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.1.tgz", - "integrity": "sha512-BpU09QqEe6ZCHuIHFphEFgvNSrubve1FtyMton26ekZ85gRGi6LrTF7zArARp2YvyFxloeiRmtSCq5sjh1WqIg==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.5.tgz", + "integrity": "sha512-CiQmBMMpMQHwM5m01YnrM6imUG1ebgYJ+fAIW4FZe6m4qHTPaRHti+R8cggAwkdz4oXhtO4/K9JWlh+8hIfR2Q==", "dev": true, "dependencies": { "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0" + "@babel/traverse": "^7.24.5", + "@babel/types": "^7.24.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", - "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.5.tgz", + "integrity": "sha512-8lLmua6AVh/8SLJRRVD6V8p73Hir9w5mJrhE+IPpILG31KKlI9iz5zmBYKcWPS59qSfgP9RaSBQSHHE81WKuEw==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-validator-identifier": "^7.24.5", "chalk": "^2.4.2", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" @@ -1126,9 +1149,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz", - "integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz", + "integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -1498,12 +1521,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.24.1.tgz", - "integrity": "sha512-h71T2QQvDgM2SmT29UYU6ozjMlAt7s7CSs5Hvy8f8cf/GM/Z4a2zMfN+fjVGaieeCrXR3EdQl6C4gQG+OgmbKw==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.24.5.tgz", + "integrity": "sha512-sMfBc3OxghjC95BkYrYocHL3NaOplrcaunblzwXhGmlPwpmfsxr4vK+mBBt49r+S240vahmv+kUxkeKgs+haCw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.5" }, "engines": { "node": ">=6.9.0" @@ -1529,12 +1552,12 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.1.tgz", - "integrity": "sha512-FUHlKCn6J3ERiu8Dv+4eoz7w8+kFLSyeVG4vDAikwADGjUCoHw/JHokyGtr8OR4UjpwPVivyF+h8Q5iv/JmrtA==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.4.tgz", + "integrity": "sha512-B8q7Pz870Hz/q9UgP8InNpY01CSLDSCyqX7zcRuv3FcPl87A2G17lASroHWaCtbdIcbYzOZ7kWmXFKbijMSmFg==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.1", + "@babel/helper-create-class-features-plugin": "^7.24.4", "@babel/helper-plugin-utils": "^7.24.0", "@babel/plugin-syntax-class-static-block": "^7.14.5" }, @@ -1546,18 +1569,18 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.24.1.tgz", - "integrity": "sha512-ZTIe3W7UejJd3/3R4p7ScyyOoafetUShSf4kCqV0O7F/RiHxVj/wRaRnQlrGwflvcehNA8M42HkAiEDYZu2F1Q==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.24.5.tgz", + "integrity": "sha512-gWkLP25DFj2dwe9Ck8uwMOpko4YsqyfZJrOmqqcegeDYEbp7rmn4U6UQZNj08UF6MaX39XenSpKRCvpDRBtZ7Q==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-plugin-utils": "^7.24.5", "@babel/helper-replace-supers": "^7.24.1", - "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-split-export-declaration": "^7.24.5", "globals": "^11.1.0" }, "engines": { @@ -1567,6 +1590,18 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-classes/node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz", + "integrity": "sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/plugin-transform-computed-properties": { "version": "7.24.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.1.tgz", @@ -1584,12 +1619,12 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.1.tgz", - "integrity": "sha512-ow8jciWqNxR3RYbSNVuF4U2Jx130nwnBnhRw6N6h1bOejNkABmcI5X5oz29K4alWX7vf1C+o6gtKXikzRKkVdw==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.5.tgz", + "integrity": "sha512-SZuuLyfxvsm+Ah57I/i1HVjveBENYK9ue8MJ7qkc7ndoNjqquJiElzA7f5yaAXjyW2hKojosOTAQQRX50bPSVg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.5" }, "engines": { "node": ">=6.9.0" @@ -1903,15 +1938,15 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.1.tgz", - "integrity": "sha512-XjD5f0YqOtebto4HGISLNfiNMTTs6tbkFf2TOqJlYKYmbo+mN9Dnpl4SRoofiziuOWMIyq3sZEUqLo3hLITFEA==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.5.tgz", + "integrity": "sha512-7EauQHszLGM3ay7a161tTQH7fj+3vVM/gThlz5HpFtnygTxjrlvoeq7MPVA1Vy9Q555OB8SnAOsMkLShNkkrHA==", "dev": true, "dependencies": { "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-plugin-utils": "^7.24.5", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.24.1" + "@babel/plugin-transform-parameters": "^7.24.5" }, "engines": { "node": ">=6.9.0" @@ -1953,12 +1988,12 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.1.tgz", - "integrity": "sha512-n03wmDt+987qXwAgcBlnUUivrZBPZ8z1plL0YvgQalLm+ZE5BMhGm94jhxXtA1wzv1Cu2aaOv1BM9vbVttrzSg==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.5.tgz", + "integrity": "sha512-xWCkmwKT+ihmA6l7SSTpk8e4qQl/274iNbSKRRS8mpqFR32ksy36+a+LWY8OXCCEefF8WFlnOHVsaDI2231wBg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-plugin-utils": "^7.24.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", "@babel/plugin-syntax-optional-chaining": "^7.8.3" }, @@ -1970,12 +2005,12 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.1.tgz", - "integrity": "sha512-8Jl6V24g+Uw5OGPeWNKrKqXPDw2YDjLc53ojwfMcKwlEoETKU9rU0mHUtcg9JntWI/QYzGAXNWEcVHZ+fR+XXg==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.5.tgz", + "integrity": "sha512-9Co00MqZ2aoky+4j2jhofErthm6QVLKbpQrvz20c3CH9KQCLHyNB+t2ya4/UrRpQGR+Wrwjg9foopoeSdnHOkA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.5" }, "engines": { "node": ">=6.9.0" @@ -2001,14 +2036,14 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.1.tgz", - "integrity": "sha512-pTHxDVa0BpUbvAgX3Gat+7cSciXqUcY9j2VZKTbSB6+VQGpNgNO9ailxTGHSXlqOnX1Hcx1Enme2+yv7VqP9bg==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.5.tgz", + "integrity": "sha512-JM4MHZqnWR04jPMujQDTBVRnqxpLLpx2tkn7iPn+Hmsc0Gnb79yvRWOkvqFOx3Z7P7VxiRIR22c4eGSNj87OBQ==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.24.1", - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-create-class-features-plugin": "^7.24.5", + "@babel/helper-plugin-utils": "^7.24.5", "@babel/plugin-syntax-private-property-in-object": "^7.14.5" }, "engines": { @@ -2155,12 +2190,12 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.1.tgz", - "integrity": "sha512-CBfU4l/A+KruSUoW+vTQthwcAdwuqbpRNB8HQKlZABwHRhsdHZ9fezp4Sn18PeAlYxTNiLMlx4xUBV3AWfg1BA==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.5.tgz", + "integrity": "sha512-UTGnhYVZtTAjdwOTzT+sCyXmTn8AhaxOS/MjG9REclZ6ULHWF9KoCZur0HSGU7hk8PdBFKKbYe6+gqdXWz84Jg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.5" }, "engines": { "node": ">=6.9.0" @@ -2382,19 +2417,19 @@ } }, "node_modules/@babel/traverse": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", - "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.5.tgz", + "integrity": "sha512-7aaBLeDQ4zYcUFDUD41lJc1fG8+5IU9DaNSJAgal866FGvmD5EbWQgnEC6kO1gGLsX0esNkfnJSndbTXA3r7UA==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.24.1", - "@babel/generator": "^7.24.1", + "@babel/code-frame": "^7.24.2", + "@babel/generator": "^7.24.5", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.24.1", - "@babel/types": "^7.24.0", + "@babel/helper-split-export-declaration": "^7.24.5", + "@babel/parser": "^7.24.5", + "@babel/types": "^7.24.5", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2403,12 +2438,12 @@ } }, "node_modules/@babel/traverse/node_modules/@babel/generator": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.1.tgz", - "integrity": "sha512-DfCRfZsBcrPEHUfuBMgbJ1Ut01Y/itOs+hY2nFLgqsqXd52/iSiVq5TITtUasIUgm+IIKdY2/1I7auiQOEeC9A==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.5.tgz", + "integrity": "sha512-x32i4hEXvr+iI0NEoEfDKzlemF8AmtOP8CcrRaEcpzysWuoEb1KknpcvMsHKPONoKZiDuItklgWhB18xEhr9PA==", "dev": true, "dependencies": { - "@babel/types": "^7.24.0", + "@babel/types": "^7.24.5", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" @@ -2417,14 +2452,26 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/traverse/node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz", + "integrity": "sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.5.tgz", + "integrity": "sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-string-parser": "^7.24.1", + "@babel/helper-validator-identifier": "^7.24.5", "to-fast-properties": "^2.0.0" }, "engines": { @@ -2511,9 +2558,9 @@ } }, "node_modules/@electron/asar": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.9.tgz", - "integrity": "sha512-Vu2P3X2gcZ3MY9W7yH72X9+AMXwUQZEJBrsPIbX0JsdllLtoh62/Q8Wg370/DawIEVKOyfD6KtTLo645ezqxUA==", + "version": "3.2.10", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.10.tgz", + "integrity": "sha512-mvBSwIBUeiRscrCeJE1LwctAriBj65eUDm0Pc11iE5gRwzkmsdbS7FnZ1XUWjpSeQWL1L5g12Fc/SchPM9DUOw==", "dev": true, "dependencies": { "commander": "^5.0.0", @@ -3142,9 +3189,9 @@ } }, "node_modules/@fontsource/roboto": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.0.12.tgz", - "integrity": "sha512-x0o17jvgoSSbS9OZnUX2+xJmVRvVCfeaYJjkS7w62iN7CuJWtMf5vJj8LqgC7ibqIkitOHVW+XssRjgrcHn62g==" + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.0.13.tgz", + "integrity": "sha512-j61DHjsdUCKMXSdNLTOxcG701FWnF0jcqNNQi2iPCDxU8seN/sMxeh62dC++UiagCWq9ghTypX+Pcy7kX+QOeQ==" }, "node_modules/@hapi/hoek": { "version": "9.3.0", @@ -3447,9 +3494,9 @@ "integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==" }, "node_modules/@ngtools/webpack": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.3.2.tgz", - "integrity": "sha512-E8zejFF4aJ8l2XcF+GgnE/1IqsZepnPT1xzulLB4LXtjVuXLFLoF9xkHQwxs7cJWWZsxd/SlNsCIcn/ezrYBcQ==", + "version": "17.3.7", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.3.7.tgz", + "integrity": "sha512-kQNS68jsPQlaWAnKcVeFKNHp6K90uQANvq+9oXb/i+JnYWzuBsHzn2r8bVdMmvjd1HdBRiGtg767XRk3u+jgRw==", "dev": true, "engines": { "node": "^18.13.0 || >=20.9.0", @@ -3498,16 +3545,16 @@ } }, "node_modules/@npmcli/agent": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.1.tgz", - "integrity": "sha512-H4FrOVtNyWC8MUwL3UfjOsAihHvT1Pe8POj3JvjXhSTJipsZMtgUALCT4mGyYZNxymkUfOw3PUj6dE4QPp6osQ==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz", + "integrity": "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==", "dev": true, "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.1" + "socks-proxy-agent": "^8.0.3" }, "engines": { "node": "^16.14.0 || >=18.0.0" @@ -3539,18 +3586,18 @@ } }, "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", "dev": true, "engines": { "node": "14 || >=16.14" } }, "node_modules/@npmcli/fs": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz", - "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz", + "integrity": "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==", "dev": true, "dependencies": { "semver": "^7.3.5" @@ -3560,15 +3607,15 @@ } }, "node_modules/@npmcli/git": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.4.tgz", - "integrity": "sha512-nr6/WezNzuYUppzXRaYu/W4aT5rLxdXqEFupbh6e/ovlYFQ8hpu1UUPV3Ir/YTl+74iXl2ZOMlGzudh9ZPUchQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.7.tgz", + "integrity": "sha512-WaOVvto604d5IpdCRV2KjQu8PzkfE96d50CQGKgywXh2GxXmDeUO5EWcBC4V57uFyrNqx83+MewuJh3WTR3xPA==", "dev": true, "dependencies": { "@npmcli/promise-spawn": "^7.0.0", "lru-cache": "^10.0.1", "npm-pick-manifest": "^9.0.0", - "proc-log": "^3.0.0", + "proc-log": "^4.0.0", "promise-inflight": "^1.0.1", "promise-retry": "^2.0.1", "semver": "^7.3.5", @@ -3588,14 +3635,23 @@ } }, "node_modules/@npmcli/git/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", "dev": true, "engines": { "node": "14 || >=16.14" } }, + "node_modules/@npmcli/git/node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/@npmcli/git/node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -3612,16 +3668,16 @@ } }, "node_modules/@npmcli/installed-package-contents": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz", - "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.1.0.tgz", + "integrity": "sha512-c8UuGLeZpm69BryRykLuKRyKFZYJsZSCT4aVY5ds4omyZqJ172ApzgfKJ5eV/r3HgLdUYgFVe54KSFVjKoe27w==", "dev": true, "dependencies": { "npm-bundled": "^3.0.0", "npm-normalize-package-bin": "^3.0.0" }, "bin": { - "installed-package-contents": "lib/index.js" + "installed-package-contents": "bin/index.js" }, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -3637,9 +3693,9 @@ } }, "node_modules/@npmcli/package-json": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.0.0.tgz", - "integrity": "sha512-OI2zdYBLhQ7kpNPaJxiflofYIpkNLi+lnGdzqUOfRmCF3r2l1nadcjtCYMJKv/Utm/ZtlffaUuTiAktPHbc17g==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.1.0.tgz", + "integrity": "sha512-1aL4TuVrLS9sf8quCLerU3H9J4vtCtgu8VauYozrmEyU57i/EdKleCnsQ7vpnABIH6c9mnTxcH5sFkO3BlV8wQ==", "dev": true, "dependencies": { "@npmcli/git": "^5.0.0", @@ -3647,7 +3703,7 @@ "hosted-git-info": "^7.0.0", "json-parse-even-better-errors": "^3.0.0", "normalize-package-data": "^6.0.0", - "proc-log": "^3.0.0", + "proc-log": "^4.0.0", "semver": "^7.5.3" }, "engines": { @@ -3655,31 +3711,31 @@ } }, "node_modules/@npmcli/package-json/node_modules/glob": { - "version": "10.3.12", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", - "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", + "version": "10.3.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.15.tgz", + "integrity": "sha512-0c6RlJt1TICLyvJYIApxb8GsXoai0KUP7AxKKAtsYXdgJR1mGEUa7DgwShbdk1nly0PYoZj01xd4hzbq3fsjpw==", "dev": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.3.6", "minimatch": "^9.0.1", "minipass": "^7.0.4", - "path-scurry": "^1.10.2" + "path-scurry": "^1.11.0" }, "bin": { "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@npmcli/package-json/node_modules/hosted-git-info": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz", - "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", "dev": true, "dependencies": { "lru-cache": "^10.0.1" @@ -3689,9 +3745,9 @@ } }, "node_modules/@npmcli/package-json/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", "dev": true, "engines": { "node": "14 || >=16.14" @@ -3712,10 +3768,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@npmcli/package-json/node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/@npmcli/promise-spawn": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.1.tgz", - "integrity": "sha512-P4KkF9jX3y+7yFUxgcUdDtLy+t4OlDGuEBLNs57AZsfSfg+uV6MLndqGpnl4831ggaEdXwR50XFoZP4VFtHolg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.2.tgz", + "integrity": "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==", "dev": true, "dependencies": { "which": "^4.0.0" @@ -3748,6 +3813,15 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/@npmcli/redact": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-1.1.0.tgz", + "integrity": "sha512-PfnWuOkQgu7gCbnSsAisaX7hKOdZ4wSAhAzH3/ph5dSGau52kCRrMMGbiSQLwyTZpgldkZ49b0brkOr1AzGBHQ==", + "dev": true, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, "node_modules/@npmcli/run-script": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.4.tgz", @@ -3799,9 +3873,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.2.tgz", - "integrity": "sha512-3XFIDKWMFZrMnao1mJhnOT1h2g0169Os848NhhmGweEcfJ4rCi+3yMCOLG4zA61rbJdkcrM/DjVZm9Hg5p5w7g==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.2.tgz", + "integrity": "sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ==", "cpu": [ "arm" ], @@ -3812,9 +3886,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.2.tgz", - "integrity": "sha512-GdxxXbAuM7Y/YQM9/TwwP+L0omeE/lJAR1J+olu36c3LqqZEBdsIWeQ91KBe6nxwOnb06Xh7JS2U5ooWU5/LgQ==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.17.2.tgz", + "integrity": "sha512-yeX/Usk7daNIVwkq2uGoq2BYJKZY1JfyLTaHO/jaiSwi/lsf8fTFoQW/n6IdAsx5tx+iotu2zCJwz8MxI6D/Bw==", "cpu": [ "arm64" ], @@ -3825,9 +3899,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.2.tgz", - "integrity": "sha512-mCMlpzlBgOTdaFs83I4XRr8wNPveJiJX1RLfv4hggyIVhfB5mJfN4P8Z6yKh+oE4Luz+qq1P3kVdWrCKcMYrrA==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.17.2.tgz", + "integrity": "sha512-kcMLpE6uCwls023+kknm71ug7MZOrtXo+y5p/tsg6jltpDtgQY1Eq5sGfHcQfb+lfuKwhBmEURDga9N0ol4YPw==", "cpu": [ "arm64" ], @@ -3838,9 +3912,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.2.tgz", - "integrity": "sha512-yUoEvnH0FBef/NbB1u6d3HNGyruAKnN74LrPAfDQL3O32e3k3OSfLrPgSJmgb3PJrBZWfPyt6m4ZhAFa2nZp2A==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.17.2.tgz", + "integrity": "sha512-AtKwD0VEx0zWkL0ZjixEkp5tbNLzX+FCqGG1SvOu993HnSz4qDI6S4kGzubrEJAljpVkhRSlg5bzpV//E6ysTQ==", "cpu": [ "x64" ], @@ -3851,9 +3925,22 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.2.tgz", - "integrity": "sha512-GYbLs5ErswU/Xs7aGXqzc3RrdEjKdmoCrgzhJWyFL0r5fL3qd1NPcDKDowDnmcoSiGJeU68/Vy+OMUluRxPiLQ==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.17.2.tgz", + "integrity": "sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.17.2.tgz", + "integrity": "sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==", "cpu": [ "arm" ], @@ -3864,9 +3951,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.2.tgz", - "integrity": "sha512-L1+D8/wqGnKQIlh4Zre9i4R4b4noxzH5DDciyahX4oOz62CphY7WDWqJoQ66zNR4oScLNOqQJfNSIAe/6TPUmQ==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.17.2.tgz", + "integrity": "sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==", "cpu": [ "arm64" ], @@ -3877,9 +3964,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.2.tgz", - "integrity": "sha512-tK5eoKFkXdz6vjfkSTCupUzCo40xueTOiOO6PeEIadlNBkadH1wNOH8ILCPIl8by/Gmb5AGAeQOFeLev7iZDOA==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.17.2.tgz", + "integrity": "sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==", "cpu": [ "arm64" ], @@ -3890,11 +3977,11 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.13.2.tgz", - "integrity": "sha512-zvXvAUGGEYi6tYhcDmb9wlOckVbuD+7z3mzInCSTACJ4DQrdSLPNUeDIcAQW39M3q6PDquqLWu7pnO39uSMRzQ==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.17.2.tgz", + "integrity": "sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==", "cpu": [ - "ppc64le" + "ppc64" ], "dev": true, "optional": true, @@ -3903,9 +3990,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.2.tgz", - "integrity": "sha512-C3GSKvMtdudHCN5HdmAMSRYR2kkhgdOfye4w0xzyii7lebVr4riCgmM6lRiSCnJn2w1Xz7ZZzHKuLrjx5620kw==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.17.2.tgz", + "integrity": "sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==", "cpu": [ "riscv64" ], @@ -3916,9 +4003,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.13.2.tgz", - "integrity": "sha512-l4U0KDFwzD36j7HdfJ5/TveEQ1fUTjFFQP5qIt9gBqBgu1G8/kCaq5Ok05kd5TG9F8Lltf3MoYsUMw3rNlJ0Yg==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.17.2.tgz", + "integrity": "sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==", "cpu": [ "s390x" ], @@ -3929,9 +4016,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.2.tgz", - "integrity": "sha512-xXMLUAMzrtsvh3cZ448vbXqlUa7ZL8z0MwHp63K2IIID2+DeP5iWIT6g1SN7hg1VxPzqx0xZdiDM9l4n9LRU1A==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.17.2.tgz", + "integrity": "sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==", "cpu": [ "x64" ], @@ -3942,9 +4029,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.2.tgz", - "integrity": "sha512-M/JYAWickafUijWPai4ehrjzVPKRCyDb1SLuO+ZyPfoXgeCEAlgPkNXewFZx0zcnoIe3ay4UjXIMdXQXOZXWqA==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.17.2.tgz", + "integrity": "sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==", "cpu": [ "x64" ], @@ -3955,9 +4042,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.2.tgz", - "integrity": "sha512-2YWwoVg9KRkIKaXSh0mz3NmfurpmYoBBTAXA9qt7VXk0Xy12PoOP40EFuau+ajgALbbhi4uTj3tSG3tVseCjuA==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.17.2.tgz", + "integrity": "sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==", "cpu": [ "arm64" ], @@ -3968,9 +4055,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.2.tgz", - "integrity": "sha512-2FSsE9aQ6OWD20E498NYKEQLneShWes0NGMPQwxWOdws35qQXH+FplabOSP5zEe1pVjurSDOGEVCE2agFwSEsw==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.17.2.tgz", + "integrity": "sha512-7II/QCSTAHuE5vdZaQEwJq2ZACkBpQDOmQsE6D6XUbnBHW8IAhm4eTufL6msLJorzrHDFv3CF8oCA/hSIRuZeQ==", "cpu": [ "ia32" ], @@ -3981,9 +4068,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.2.tgz", - "integrity": "sha512-7h7J2nokcdPePdKykd8wtc8QqqkqxIrUz7MHj6aNr8waBRU//NLDVnNjQnqQO6fqtjrtCdftpbTuOKAyrAQETQ==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.17.2.tgz", + "integrity": "sha512-TGGO7v7qOq4CYmSBVEYpI1Y5xDuCEnbVC5Vth8mOsW0gDSzxNrVERPc790IGHsrT2dQSimgMr9Ub3Y1Jci5/8w==", "cpu": [ "x64" ], @@ -3994,13 +4081,13 @@ ] }, "node_modules/@schematics/angular": { - "version": "17.3.2", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.3.2.tgz", - "integrity": "sha512-zPINvow0Qo6ionnDl25ZzSSLDyDxBjqRPEJWGHU70expbjXK4A2caQT9P/8ImhapbJAXJCfxg4GF9z1d/sWe4w==", + "version": "17.3.7", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.3.7.tgz", + "integrity": "sha512-HaJroKaberriP4wFefTTSVFrtU9GMvnG3I6ELbOteOyKMH7o2V91FXGJDJ5KnIiLRlBmC30G3r+9Ybc/rtAYkw==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.3.2", - "@angular-devkit/schematics": "17.3.2", + "@angular-devkit/core": "17.3.7", + "@angular-devkit/schematics": "17.3.7", "jsonc-parser": "3.2.1" }, "engines": { @@ -4031,12 +4118,12 @@ "dev": true }, "node_modules/@sigstore/bundle": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.2.0.tgz", - "integrity": "sha512-5VI58qgNs76RDrwXNhpmyN/jKpq9evV/7f1XrcqcAfvxDl5SeVY/I5Rmfe96ULAV7/FK5dge9RBKGBJPhL1WsQ==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.3.2.tgz", + "integrity": "sha512-wueKWDk70QixNLB363yHc2D2ItTgYiMTdPwK8D9dKQMR3ZQ0c35IxP5xnwQ8cNLoCgCRcHf14kE+CLIvNX1zmA==", "dev": true, "dependencies": { - "@sigstore/protobuf-specs": "^0.3.0" + "@sigstore/protobuf-specs": "^0.3.2" }, "engines": { "node": "^16.14.0 || >=18.0.0" @@ -4052,51 +4139,62 @@ } }, "node_modules/@sigstore/protobuf-specs": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.0.tgz", - "integrity": "sha512-zxiQ66JFOjVvP9hbhGj/F/qNdsZfkGb/dVXSanNRNuAzMlr4MC95voPUBX8//ZNnmv3uSYzdfR/JSkrgvZTGxA==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.2.tgz", + "integrity": "sha512-c6B0ehIWxMI8wiS/bj6rHMPqeFvngFV7cDU/MY+B16P9Z3Mp9k8L93eYZ7BYzSickzuqAQqAq0V956b3Ju6mLw==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^16.14.0 || >=18.0.0" } }, "node_modules/@sigstore/sign": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.2.3.tgz", - "integrity": "sha512-LqlA+ffyN02yC7RKszCdMTS6bldZnIodiox+IkT8B2f8oRYXCB3LQ9roXeiEL21m64CVH1wyveYAORfD65WoSw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.3.2.tgz", + "integrity": "sha512-5Vz5dPVuunIIvC5vBb0APwo7qKA4G9yM48kPWJT+OEERs40md5GoUR1yedwpekWZ4m0Hhw44m6zU+ObsON+iDA==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.2.0", + "@sigstore/bundle": "^2.3.2", "@sigstore/core": "^1.0.0", - "@sigstore/protobuf-specs": "^0.3.0", - "make-fetch-happen": "^13.0.0" + "@sigstore/protobuf-specs": "^0.3.2", + "make-fetch-happen": "^13.0.1", + "proc-log": "^4.2.0", + "promise-retry": "^2.0.1" }, "engines": { "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/@sigstore/sign/node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/@sigstore/tuf": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.2.tgz", - "integrity": "sha512-mwbY1VrEGU4CO55t+Kl6I7WZzIl+ysSzEYdA1Nv/FTrl2bkeaPXo5PnWZAVfcY2zSdhOpsUTJW67/M2zHXGn5w==", + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.4.tgz", + "integrity": "sha512-44vtsveTPUpqhm9NCrbU8CWLe3Vck2HO1PNLw7RIajbB7xhtn5RBPm1VNSCMwqGYHhDsBJG8gDF0q4lgydsJvw==", "dev": true, "dependencies": { - "@sigstore/protobuf-specs": "^0.3.0", - "tuf-js": "^2.2.0" + "@sigstore/protobuf-specs": "^0.3.2", + "tuf-js": "^2.2.1" }, "engines": { "node": "^16.14.0 || >=18.0.0" } }, "node_modules/@sigstore/verify": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-1.1.1.tgz", - "integrity": "sha512-BNANJms49rw9Q5J+fJjrDqOQSzjXDcOq/pgKDaVdDoIvQwqIfaoUriy+fQfh8sBX04hr4bkkrwu3EbhQqoQH7A==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-1.2.1.tgz", + "integrity": "sha512-8iKx79/F73DKbGfRf7+t4dqrc0bRr0thdPrxAtCKWRm/F0tG71i6O1rvlnScncJLLBZHn3h8M3c1BSUAb9yu8g==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.2.0", + "@sigstore/bundle": "^2.3.2", "@sigstore/core": "^1.1.0", - "@sigstore/protobuf-specs": "^0.3.0" + "@sigstore/protobuf-specs": "^0.3.2" }, "engines": { "node": "^16.14.0 || >=18.0.0" @@ -4169,13 +4267,13 @@ } }, "node_modules/@tufjs/models": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.0.tgz", - "integrity": "sha512-c8nj8BaOExmZKO2DXhDfegyhSGcG9E/mPN3U13L+/PsoWm1uaGiHHjxqSHQiasDBQwDA3aHuw9+9spYAP1qvvg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.1.tgz", + "integrity": "sha512-92F7/SFyufn4DXsha9+QfKnN03JGqtMFMXgSHbZOo8JG59WkTni7UzAouNQDf7AuP9OAMxVOPQcqG3sB7w+kkg==", "dev": true, "dependencies": { "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.3" + "minimatch": "^9.0.4" }, "engines": { "node": "^16.14.0 || >=18.0.0" @@ -4256,9 +4354,9 @@ } }, "node_modules/@types/eslint": { - "version": "8.56.6", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.6.tgz", - "integrity": "sha512-ymwc+qb1XkjT/gfoQwxIeHZ6ixH23A+tCT2ADSA/DPVKzAjwYkTXBMCQ/f6fe4wEa85Lhp26VPeUxI7wMhAi7A==", + "version": "8.56.10", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", + "integrity": "sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==", "dev": true, "dependencies": { "@types/estree": "*", @@ -4294,9 +4392,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "4.17.43", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz", - "integrity": "sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==", + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", + "integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==", "dev": true, "dependencies": { "@types/node": "*", @@ -4357,9 +4455,9 @@ } }, "node_modules/@types/leaflet": { - "version": "1.9.8", - "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.8.tgz", - "integrity": "sha512-EXdsL4EhoUtGm2GC2ZYtXn+Fzc6pluVgagvo2VC1RHWToLGlTRwVYoDpqS/7QXa01rmDyBjJk3Catpf60VMkwg==", + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.12.tgz", + "integrity": "sha512-BK7XS+NyRI291HIo0HCfE18Lp8oA30H1gpi1tf0mF3TgiCEzanQjOqNZ4x126SXzzi2oNSZhZ5axJp1k0iM6jg==", "dev": true, "dependencies": { "@types/geojson": "*" @@ -4378,9 +4476,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.2.tgz", - "integrity": "sha512-zQ0NYO87hyN6Xrclcqp7f8ZbXNbRfoGWNcMvHTPQp9UUrwI0mI7XBz+cu7/W6/VClYo2g63B0cjull/srU7LgQ==", + "version": "20.12.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", + "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -4407,9 +4505,9 @@ } }, "node_modules/@types/qs": { - "version": "6.9.14", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.14.tgz", - "integrity": "sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==", + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==", "dev": true }, "node_modules/@types/range-parser": { @@ -4453,14 +4551,14 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", - "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", "dev": true, "dependencies": { "@types/http-errors": "*", - "@types/mime": "*", - "@types/node": "*" + "@types/node": "*", + "@types/send": "*" } }, "node_modules/@types/sockjs": { @@ -5364,13 +5462,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.10", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz", - "integrity": "sha512-rpIuu//y5OX6jVU+a5BCn1R5RSZYWAl2Nar76iwaOdycqb6JPxediskWFMMl7stfwNJR4b7eiQvh5fB5TEQJTQ==", + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz", + "integrity": "sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q==", "dev": true, "dependencies": { "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.1", + "@babel/helper-define-polyfill-provider": "^0.6.2", "semver": "^6.3.1" }, "peerDependencies": { @@ -6045,15 +6143,6 @@ "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==", "dev": true }, - "node_modules/builtins": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz", - "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==", - "dev": true, - "dependencies": { - "semver": "^7.0.0" - } - }, "node_modules/bytes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", @@ -6064,9 +6153,9 @@ } }, "node_modules/cacache": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.2.tgz", - "integrity": "sha512-r3NU8h/P+4lVUHfeRw1dtgQYar3DZMm4/cm2bZgOvrFC/su7budSOeqh52VJIC4U4iG1WWwV6vRW0znqBvxNuw==", + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.3.tgz", + "integrity": "sha512-qXCd4rh6I07cnDqh8V48/94Tc/WSfj+o3Gn6NZ0aZovS255bUx8O13uKxRFd2eWG0xgsco7+YItQNPaa5E85hg==", "dev": true, "dependencies": { "@npmcli/fs": "^3.1.0", @@ -6087,31 +6176,31 @@ } }, "node_modules/cacache/node_modules/glob": { - "version": "10.3.12", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", - "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", + "version": "10.3.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.15.tgz", + "integrity": "sha512-0c6RlJt1TICLyvJYIApxb8GsXoai0KUP7AxKKAtsYXdgJR1mGEUa7DgwShbdk1nly0PYoZj01xd4hzbq3fsjpw==", "dev": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.3.6", "minimatch": "^9.0.1", "minipass": "^7.0.4", - "path-scurry": "^1.10.2" + "path-scurry": "^1.11.0" }, "bin": { "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/cacache/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", "dev": true, "engines": { "node": "14 || >=16.14" @@ -6197,9 +6286,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001603", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001603.tgz", - "integrity": "sha512-iL2iSS0eDILMb9n5yKQoTBim9jMZ0Yrk8g0N9K7UzYyWnfIKzXBZD5ngpM37ZcL/cv0Mli8XtVMRYMQAfFpi5Q==", + "version": "1.0.30001620", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001620.tgz", + "integrity": "sha512-WJvYsOjd1/BYUY6SNGUosK9DUidBPDTnOARHp3fSmFO1ekdxaY6nKRttEVrfMmYi80ctS0kz1wiWmm14fVc3ew==", "dev": true, "funding": [ { @@ -6237,9 +6326,9 @@ "dev": true }, "node_modules/chart.js": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.2.tgz", - "integrity": "sha512-6GD7iKwFpP5kbSD4MeRRRlTnQvxfQREy36uEtm1hzHzcOqwWx0YEHuspuoNlslu+nciLIB7fjjsHkUv/FzFcOg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.3.tgz", + "integrity": "sha512-qK1gkGSRYcJzqrrzdR6a+I0vQ4/R+SoODXyAjscQ/4mzuNzySaMCd+hyVxitSY1+L2fjPD1Gbn+ibNqRmwQeLw==", "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -6630,22 +6719,22 @@ } }, "node_modules/config-file-ts/node_modules/glob": { - "version": "10.3.12", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", - "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", + "version": "10.3.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.15.tgz", + "integrity": "sha512-0c6RlJt1TICLyvJYIApxb8GsXoai0KUP7AxKKAtsYXdgJR1mGEUa7DgwShbdk1nly0PYoZj01xd4hzbq3fsjpw==", "dev": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.3.6", "minimatch": "^9.0.1", "minipass": "^7.0.4", - "path-scurry": "^1.10.2" + "path-scurry": "^1.11.0" }, "bin": { "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6778,9 +6867,9 @@ } }, "node_modules/core-js-compat": { - "version": "3.36.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.1.tgz", - "integrity": "sha512-Dk997v9ZCt3X/npqzyGdTlq6t7lDBhZwGvV94PKzDArjp7BTRm7WlDAXYd/OWdeFHO8OChQYRJNJvUCqCbrtKA==", + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz", + "integrity": "sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==", "dev": true, "dependencies": { "browserslist": "^4.23.0" @@ -7634,9 +7723,9 @@ "dev": true }, "node_modules/ejs": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", - "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dev": true, "dependencies": { "jake": "^10.8.5" @@ -7649,9 +7738,9 @@ } }, "node_modules/electron": { - "version": "29.1.6", - "resolved": "https://registry.npmjs.org/electron/-/electron-29.1.6.tgz", - "integrity": "sha512-UIYfpHR9gRBFKHyejHuXUVQ7nNzZRnoPVOHlijkvqR+DSLwgJ2ZcVVt0LNduNeO8PhPkY1+6kHonL52OTC1cOw==", + "version": "30.0.6", + "resolved": "https://registry.npmjs.org/electron/-/electron-30.0.6.tgz", + "integrity": "sha512-PkhEPFdpYcTzjAO3gMHZ+map7g2+xCrMDedo/L1i0ir2BRXvAB93IkTJX497U6Srb/09r2cFt+k20VPNVCdw3Q==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -7848,43 +7937,6 @@ "node": ">= 10.0.0" } }, - "node_modules/electron-debug": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/electron-debug/-/electron-debug-3.2.0.tgz", - "integrity": "sha512-7xZh+LfUvJ52M9rn6N+tPuDw6oRAjxUj9SoxAZfJ0hVCXhZCsdkrSt7TgXOiWiEOBgEV8qwUIO/ScxllsPS7ow==", - "dev": true, - "dependencies": { - "electron-is-dev": "^1.1.0", - "electron-localshortcut": "^3.1.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/electron-is-accelerator": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/electron-is-accelerator/-/electron-is-accelerator-0.1.2.tgz", - "integrity": "sha512-fLGSAjXZtdn1sbtZxx52+krefmtNuVwnJCV2gNiVt735/ARUboMl8jnNC9fZEqQdlAv2ZrETfmBUsoQci5evJA==", - "dev": true - }, - "node_modules/electron-is-dev": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/electron-is-dev/-/electron-is-dev-1.2.0.tgz", - "integrity": "sha512-R1oD5gMBPS7PVU8gJwH6CtT0e6VSoD0+SzSnYpNm+dBkcijgA+K7VAMHDfnRq/lkKPZArpzplTW6jfiMYosdzw==", - "dev": true - }, - "node_modules/electron-localshortcut": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/electron-localshortcut/-/electron-localshortcut-3.2.1.tgz", - "integrity": "sha512-DWvhKv36GsdXKnaFFhEiK8kZZA+24/yFLgtTwJJHc7AFgDjNRIBJZ/jq62Y/dWv9E4ypYwrVWN2bVrCYw1uv7Q==", - "dev": true, - "dependencies": { - "debug": "^4.0.1", - "electron-is-accelerator": "^0.1.0", - "keyboardevent-from-electron-accelerator": "^2.0.0", - "keyboardevents-areequal": "^0.2.1" - } - }, "node_modules/electron-publish": { "version": "24.13.1", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz", @@ -8006,9 +8058,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.722", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.722.tgz", - "integrity": "sha512-5nLE0TWFFpZ80Crhtp4pIp8LXCztjYX41yUcV6b+bKR2PqzjskTMOOlBi1VjBHlvHwS+4gar7kNKOrsbsewEZQ==", + "version": "1.4.774", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.774.tgz", + "integrity": "sha512-132O1XCd7zcTkzS3FgkAzKmnBuNJjK8WjcTtNuoylj7MYbqw5eXehjQ5OK91g0zm7OTKIPeaAG4CPoRfD9M1Mg==", "dev": true }, "node_modules/elliptic": { @@ -8076,9 +8128,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.16.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.0.tgz", - "integrity": "sha512-O+QWCviPNSSLAD9Ucn8Awv+poAkqn3T1XY5/N7kR7rQO9yfSGWkYZDwpJ+iKF7B8rxaQKWngSqACpgzeapSyoA==", + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.1.tgz", + "integrity": "sha512-4U5pNsuDl0EhuZpq46M5xPslstkviJuhrdobaRDBk2Jy2KO37FDAJl4lb2KlNabxT0m4MTK2UHNrsAcphE8nyw==", "dev": true, "dependencies": { "graceful-fs": "^4.2.4", @@ -8219,9 +8271,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", - "integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.3.tgz", + "integrity": "sha512-i1gCgmR9dCl6Vil6UKPI/trA69s08g/syhiDK9TG0Nf1RJjjFI+AzoWW7sPufzkgYAn861skuCwJa0pIIHYxvg==", "dev": true }, "node_modules/es-object-atoms": { @@ -8927,9 +8979,9 @@ } }, "node_modules/fs-monkey": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.5.tgz", - "integrity": "sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", + "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==", "dev": true }, "node_modules/fs.realpath": { @@ -9154,12 +9206,13 @@ } }, "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, "dependencies": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -9690,9 +9743,9 @@ } }, "node_modules/ignore-walk": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.4.tgz", - "integrity": "sha512-t7sv42WkwFkyKbivUCglsQW5YWMskWtbEf4MNKX5u/CCWHKSPzN4FtBQGsQZgCLbxOzpVlcbWVK5KB3auIOjSw==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.5.tgz", + "integrity": "sha512-VuuG0wCnjhnylG1ABXT3dAuIpTNDs/G8jlpmwXY03fXoXy/8ZK8/T+hMzt8L4WnrLCJgdybqgPagnF/f97cg3A==", "dev": true, "dependencies": { "minimatch": "^9.0.0" @@ -9730,9 +9783,9 @@ } }, "node_modules/immutable": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", - "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", + "integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==", "dev": true }, "node_modules/import-fresh": { @@ -9883,9 +9936,9 @@ "dev": true }, "node_modules/ipaddr.js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", - "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", "dev": true, "engines": { "node": ">= 10" @@ -10402,9 +10455,9 @@ } }, "node_modules/jake": { - "version": "10.8.7", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", - "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.1.tgz", + "integrity": "sha512-61btcOHNnLnsOdtLgA5efqQWjnSi/vow5HbI7HMdKKWqvrKR1bLK3BPlJn9gcSaP2ewuamUSMB5XEy76KUIS2w==", "dev": true, "dependencies": { "async": "^3.2.3", @@ -10559,9 +10612,9 @@ } }, "node_modules/joi": { - "version": "17.12.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.12.2.tgz", - "integrity": "sha512-RonXAIzCiHLc8ss3Ibuz45u28GOsWE1UpfDXLbN/9NKbL4tCJf8TWYVKsoYuuh+sAUt7fsSNpA+r2+TBA6Wjmw==", + "version": "17.13.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.1.tgz", + "integrity": "sha512-vaBlIKCyo4FCUtCm7Eu4QZd/q02bWcxfUO6YSXAZOWF6gzcLBeba8kwotUdYJjDLW8Cz8RywsSOqiNJZW0mNvg==", "dev": true, "dependencies": { "@hapi/hoek": "^9.3.0", @@ -10621,9 +10674,9 @@ "dev": true }, "node_modules/json-parse-even-better-errors": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", - "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", + "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==", "dev": true, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -10687,18 +10740,6 @@ "source-map-support": "^0.5.5" } }, - "node_modules/keyboardevent-from-electron-accelerator": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/keyboardevent-from-electron-accelerator/-/keyboardevent-from-electron-accelerator-2.0.0.tgz", - "integrity": "sha512-iQcmNA0M4ETMNi0kG/q0h/43wZk7rMeKYrXP7sqKIJbHkTU8Koowgzv+ieR/vWJbOwxx5nDC3UnudZ0aLSu4VA==", - "dev": true - }, - "node_modules/keyboardevents-areequal": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/keyboardevents-areequal/-/keyboardevents-areequal-0.2.2.tgz", - "integrity": "sha512-Nv+Kr33T0mEjxR500q+I6IWisOQ0lK1GGOncV0kWE6n4KFmpcu7RUX5/2B0EUtX51Cb0HjZ9VJsSY3u4cBa0kw==", - "dev": true - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -11153,9 +11194,9 @@ "dev": true }, "node_modules/make-fetch-happen": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.0.tgz", - "integrity": "sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", + "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==", "dev": true, "dependencies": { "@npmcli/agent": "^2.0.0", @@ -11167,6 +11208,7 @@ "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.3", + "proc-log": "^4.2.0", "promise-retry": "^2.0.1", "ssri": "^10.0.0" }, @@ -11174,6 +11216,15 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/make-fetch-happen/node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -11420,9 +11471,9 @@ } }, "node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.1.tgz", + "integrity": "sha512-UZ7eQ+h8ywIRAW1hIEl2AqdwzJucU/Kp59+8kkZeSvafXhZjul247BvIJjEVFVeON6d7lM46XX1HXCduKAS8VA==", "dev": true, "engines": { "node": ">=16 || 14 >=14.17" @@ -11441,9 +11492,9 @@ } }, "node_modules/minipass-fetch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.4.tgz", - "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.5.tgz", + "integrity": "sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==", "dev": true, "dependencies": { "minipass": "^7.0.3", @@ -11787,9 +11838,9 @@ } }, "node_modules/node-gyp-build": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.0.tgz", - "integrity": "sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.1.tgz", + "integrity": "sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==", "dev": true, "optional": true, "bin": { @@ -11799,22 +11850,22 @@ } }, "node_modules/node-gyp/node_modules/glob": { - "version": "10.3.12", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", - "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", + "version": "10.3.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.15.tgz", + "integrity": "sha512-0c6RlJt1TICLyvJYIApxb8GsXoai0KUP7AxKKAtsYXdgJR1mGEUa7DgwShbdk1nly0PYoZj01xd4hzbq3fsjpw==", "dev": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.3.6", "minimatch": "^9.0.1", "minipass": "^7.0.4", - "path-scurry": "^1.10.2" + "path-scurry": "^1.11.0" }, "bin": { "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -11938,9 +11989,9 @@ } }, "node_modules/node-polyfill-webpack-plugin/node_modules/type-fest": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.14.0.tgz", - "integrity": "sha512-on5/Cw89wwqGZQu+yWO0gGMGu8VNxsaW9SB2HE8yJjllEk7IDTwnSN1dUVldYILhYPN5HzD7WAaw2cc/jBfn0Q==", + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.18.2.tgz", + "integrity": "sha512-+suCYpfJLAe4OXS6+PPXjW3urOS4IoP9waSiLuXfLgqZODKw/aWwASvzqE886wA0kQgGy0mIWyhd87VpqIy6Xg==", "dev": true, "engines": { "node": ">=16" @@ -11956,9 +12007,9 @@ "dev": true }, "node_modules/nopt": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.0.tgz", - "integrity": "sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", "dev": true, "dependencies": { "abbrev": "^2.0.0" @@ -11971,9 +12022,9 @@ } }, "node_modules/normalize-package-data": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.0.tgz", - "integrity": "sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.1.tgz", + "integrity": "sha512-6rvCfeRW+OEZagAB4lMLSNuTNYZWLVtKccK79VSTf//yTY5VOCgcpH80O+bZK8Neps7pUnd5G+QlMg1yV/2iZQ==", "dev": true, "dependencies": { "hosted-git-info": "^7.0.0", @@ -11986,9 +12037,9 @@ } }, "node_modules/normalize-package-data/node_modules/hosted-git-info": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz", - "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", "dev": true, "dependencies": { "lru-cache": "^10.0.1" @@ -11998,9 +12049,9 @@ } }, "node_modules/normalize-package-data/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", "dev": true, "engines": { "node": "14 || >=16.14" @@ -12037,9 +12088,9 @@ } }, "node_modules/npm-bundled": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz", - "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.1.tgz", + "integrity": "sha512-+AvaheE/ww1JEwRHOrn4WHNzOxGtVp+adrg2AeZS/7KuxGUYFuBta98wYpfHBbJp6Tg6j1NKSEVHNcfZzJHQwQ==", "dev": true, "dependencies": { "npm-normalize-package-bin": "^3.0.0" @@ -12085,9 +12136,9 @@ } }, "node_modules/npm-package-arg/node_modules/hosted-git-info": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz", - "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", "dev": true, "dependencies": { "lru-cache": "^10.0.1" @@ -12097,9 +12148,9 @@ } }, "node_modules/npm-package-arg/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", "dev": true, "engines": { "node": "14 || >=16.14" @@ -12133,23 +12184,33 @@ } }, "node_modules/npm-registry-fetch": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.1.0.tgz", - "integrity": "sha512-PQCELXKt8Azvxnt5Y85GseQDJJlglTFM9L9U9gkv2y4e9s0k3GVDdOx3YoB6gm2Do0hlkzC39iCGXby+Wve1Bw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.2.1.tgz", + "integrity": "sha512-8l+7jxhim55S85fjiDGJ1rZXBWGtRLi1OSb4Z3BPLObPuIaeKRlPRiYMSHU4/81ck3t71Z+UwDDl47gcpmfQQA==", "dev": true, "dependencies": { + "@npmcli/redact": "^1.1.0", "make-fetch-happen": "^13.0.0", "minipass": "^7.0.2", "minipass-fetch": "^3.0.0", "minipass-json-stream": "^1.0.1", "minizlib": "^2.1.2", "npm-package-arg": "^11.0.0", - "proc-log": "^3.0.0" + "proc-log": "^4.0.0" }, "engines": { "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/npm-registry-fetch/node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/npm-run-all": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", @@ -12795,25 +12856,25 @@ "dev": true }, "node_modules/path-scurry": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.2.tgz", - "integrity": "sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", "dev": true, "engines": { "node": "14 || >=16.14" @@ -12857,9 +12918,9 @@ "dev": true }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", "dev": true }, "node_modules/picomatch": { @@ -13079,9 +13140,9 @@ "dev": true }, "node_modules/postcss-modules-extract-imports": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", - "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", "dev": true, "engines": { "node": "^10 || ^12 || >= 14" @@ -13091,9 +13152,9 @@ } }, "node_modules/postcss-modules-local-by-default": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.4.tgz", - "integrity": "sha512-L4QzMnOdVwRm1Qb8m4x8jsZzKAaPAgrUF1r/hjDR2Xj7R+8Zsf97jAlSQzWtKx5YNiNGN8QxmPFIc/sh+RQl+Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.5.tgz", + "integrity": "sha512-6MieY7sIfTK0hYfafw1OMEG+2bg8Q1ocHCpoWLqOKj3JXlKu4G7btkmM/B7lFubYkYWmRSPLZi5chid63ZaZYw==", "dev": true, "dependencies": { "icss-utils": "^5.0.0", @@ -13108,9 +13169,9 @@ } }, "node_modules/postcss-modules-scope": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.1.1.tgz", - "integrity": "sha512-uZgqzdTleelWjzJY+Fhti6F3C9iF1JR/dODLs/JDefozYcKTBCdD8BIl6nNPbTbcLnGrk56hzwZC2DaGNvYjzA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.0.tgz", + "integrity": "sha512-oq+g1ssrsZOsx9M96c5w8laRmvEu9C3adDSjI8oTcbfkrTE8hx/zfyobUoWIxaKPO8bt6S62kxpw5GqypEw1QQ==", "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.4" @@ -13167,9 +13228,9 @@ "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==" }, "node_modules/primeng": { - "version": "17.12.0", - "resolved": "https://registry.npmjs.org/primeng/-/primeng-17.12.0.tgz", - "integrity": "sha512-RxUkmHliMxal8g1jy6gVe5HkwQQWZRz0BkV//d91wKnk0LP7vNJTbVU58ihSL9gAn/XkPW3xaR4heX585ml5yg==", + "version": "17.17.0", + "resolved": "https://registry.npmjs.org/primeng/-/primeng-17.17.0.tgz", + "integrity": "sha512-+lIfG2nVve5GJQXGBDi2YeVabg6E9RmG67LDw9Ol8XvMWuHwJXQAGfO+AKPhPPzFSdb1j2v44uJemuNcJLXUiw==", "dependencies": { "tslib": "^2.3.0" }, @@ -13308,9 +13369,9 @@ } }, "node_modules/qs": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.0.tgz", - "integrity": "sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==", + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", + "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", "dev": true, "dependencies": { "side-channel": "^1.0.6" @@ -13463,9 +13524,9 @@ } }, "node_modules/read-package-json": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-7.0.0.tgz", - "integrity": "sha512-uL4Z10OKV4p6vbdvIXB+OzhInYtIozl/VxUBPgNkBuUi2DeRonnuspmaVAMcrkmfjKGNmRndyQAbE7/AmzGwFg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-7.0.1.tgz", + "integrity": "sha512-8PcDiZ8DXUjLf687Ol4BR8Bpm2umR7vhoZOzNRt+uxD9GpBh/K+CAAALVIiYFknmvlmyg7hM7BSNUXPaCCqd0Q==", "dev": true, "dependencies": { "glob": "^10.2.2", @@ -13491,22 +13552,22 @@ } }, "node_modules/read-package-json/node_modules/glob": { - "version": "10.3.12", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", - "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", + "version": "10.3.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.15.tgz", + "integrity": "sha512-0c6RlJt1TICLyvJYIApxb8GsXoai0KUP7AxKKAtsYXdgJR1mGEUa7DgwShbdk1nly0PYoZj01xd4hzbq3fsjpw==", "dev": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.3.6", "minimatch": "^9.0.1", "minipass": "^7.0.4", - "path-scurry": "^1.10.2" + "path-scurry": "^1.11.0" }, "bin": { "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -13928,9 +13989,9 @@ "optional": true }, "node_modules/rollup": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.2.tgz", - "integrity": "sha512-MIlLgsdMprDBXC+4hsPgzWUasLO9CE4zOkj/u6j+Z6j5A4zRY+CtiXAdJyPtgCsc42g658Aeh1DlrdVEJhsL2g==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.17.2.tgz", + "integrity": "sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -13943,21 +14004,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.13.2", - "@rollup/rollup-android-arm64": "4.13.2", - "@rollup/rollup-darwin-arm64": "4.13.2", - "@rollup/rollup-darwin-x64": "4.13.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.13.2", - "@rollup/rollup-linux-arm64-gnu": "4.13.2", - "@rollup/rollup-linux-arm64-musl": "4.13.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.13.2", - "@rollup/rollup-linux-riscv64-gnu": "4.13.2", - "@rollup/rollup-linux-s390x-gnu": "4.13.2", - "@rollup/rollup-linux-x64-gnu": "4.13.2", - "@rollup/rollup-linux-x64-musl": "4.13.2", - "@rollup/rollup-win32-arm64-msvc": "4.13.2", - "@rollup/rollup-win32-ia32-msvc": "4.13.2", - "@rollup/rollup-win32-x64-msvc": "4.13.2", + "@rollup/rollup-android-arm-eabi": "4.17.2", + "@rollup/rollup-android-arm64": "4.17.2", + "@rollup/rollup-darwin-arm64": "4.17.2", + "@rollup/rollup-darwin-x64": "4.17.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.17.2", + "@rollup/rollup-linux-arm-musleabihf": "4.17.2", + "@rollup/rollup-linux-arm64-gnu": "4.17.2", + "@rollup/rollup-linux-arm64-musl": "4.17.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.17.2", + "@rollup/rollup-linux-riscv64-gnu": "4.17.2", + "@rollup/rollup-linux-s390x-gnu": "4.17.2", + "@rollup/rollup-linux-x64-gnu": "4.17.2", + "@rollup/rollup-linux-x64-musl": "4.17.2", + "@rollup/rollup-win32-arm64-msvc": "4.17.2", + "@rollup/rollup-win32-ia32-msvc": "4.17.2", + "@rollup/rollup-win32-x64-msvc": "4.17.2", "fsevents": "~2.3.2" } }, @@ -14524,17 +14586,17 @@ "dev": true }, "node_modules/sigstore": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.2.2.tgz", - "integrity": "sha512-2A3WvXkQurhuMgORgT60r6pOWiCOO5LlEqY2ADxGBDGVYLSo5HN0uLtb68YpVpuL/Vi8mLTe7+0Dx2Fq8lLqEg==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.3.1.tgz", + "integrity": "sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.2.0", + "@sigstore/bundle": "^2.3.2", "@sigstore/core": "^1.0.0", - "@sigstore/protobuf-specs": "^0.3.0", - "@sigstore/sign": "^2.2.3", - "@sigstore/tuf": "^2.3.1", - "@sigstore/verify": "^1.1.0" + "@sigstore/protobuf-specs": "^0.3.2", + "@sigstore/sign": "^2.3.2", + "@sigstore/tuf": "^2.3.4", + "@sigstore/verify": "^1.2.1" }, "engines": { "node": "^16.14.0 || >=18.0.0" @@ -14646,9 +14708,9 @@ } }, "node_modules/socks": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.1.tgz", - "integrity": "sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", "dev": true, "dependencies": { "ip-address": "^9.0.5", @@ -14811,9 +14873,9 @@ "dev": true }, "node_modules/ssri": { - "version": "10.0.5", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz", - "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==", + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", + "integrity": "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==", "dev": true, "dependencies": { "minipass": "^7.0.3" @@ -15490,14 +15552,14 @@ "dev": true }, "node_modules/tuf-js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.0.tgz", - "integrity": "sha512-ZSDngmP1z6zw+FIkIBjvOp/II/mIub/O7Pp12j1WNsiCpg5R5wAc//i555bBQsE44O94btLt0xM/Zr2LQjwdCg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.1.tgz", + "integrity": "sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA==", "dev": true, "dependencies": { - "@tufjs/models": "2.0.0", + "@tufjs/models": "2.0.1", "debug": "^4.3.4", - "make-fetch-happen": "^13.0.0" + "make-fetch-happen": "^13.0.1" }, "engines": { "node": "^16.14.0 || >=18.0.0" @@ -15608,9 +15670,9 @@ "dev": true }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -15636,9 +15698,9 @@ } }, "node_modules/undici": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.7.1.tgz", - "integrity": "sha512-+Wtb9bAQw6HYWzCnxrPTMVEV3Q1QjYanI0E4q02ehReMuquQdLTEFEYbfs7hcImVYKcQkWSwT6buEmSVIiDDtQ==", + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.11.1.tgz", + "integrity": "sha512-KyhzaLJnV1qa3BSHdj4AZ2ndqI0QWPxYzaIOio0WzcEJB9gvuysprJSLtpvc2D9mhR9jPDUk7xlJlZbH2KR5iw==", "dev": true, "engines": { "node": ">=18.0" @@ -15733,9 +15795,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", + "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", "dev": true, "funding": [ { @@ -15752,8 +15814,8 @@ } ], "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -15788,9 +15850,9 @@ "dev": true }, "node_modules/utf8-byte-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", - "integrity": "sha512-4+wkEYLBbWxqTahEsWrhxepcoVOJ+1z5PGIjPZxRkytcdSUaNjIjBM7Xn8E+pdSuV7SzvWovBFA54FO0JSoqhA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", "dev": true }, "node_modules/util": { @@ -15850,13 +15912,10 @@ } }, "node_modules/validate-npm-package-name": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz", - "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", "dev": true, - "dependencies": { - "builtins": "^5.0.0" - }, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } @@ -15886,9 +15945,9 @@ } }, "node_modules/vite": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.5.tgz", - "integrity": "sha512-BdN1xh0Of/oQafhU+FvopafUp6WaYenLU/NFoL5WyJL++GxkNfieKzBhM24H3HVsPQrlAqB7iJYTHabzaRed5Q==", + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.7.tgz", + "integrity": "sha512-sgnEEFTZYMui/sTlH1/XEnVNHMujOahPLGMxn1+5sIT45Xjng1Ec1K78jRP15dSmVgg5WBin9yO81j3o9OxofA==", "dev": true, "dependencies": { "esbuild": "^0.19.3", @@ -16867,9 +16926,9 @@ "dev": true }, "node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", + "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", "dev": true, "engines": { "node": ">=10.0.0" @@ -17016,12 +17075,9 @@ } }, "node_modules/zone.js": { - "version": "0.14.4", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.4.tgz", - "integrity": "sha512-NtTUvIlNELez7Q1DzKVIFZBzNb646boQMgpATo9z3Ftuu/gWvzxCW7jdjcUDoRGxRikrhVHB/zLXh1hxeJawvw==", - "dependencies": { - "tslib": "^2.3.0" - } + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.6.tgz", + "integrity": "sha512-vyRNFqofdaHVdWAy7v3Bzmn84a1JHWSjpuTZROT/uYn8I3p2cmo7Ro9twFmYRQDPhiYOV7QLk0hhY4JJQVqS6Q==" } } } diff --git a/desktop/package.json b/desktop/package.json index f4cc366eb..e97e2dbd6 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -24,23 +24,26 @@ "electron:serve": "wait-on tcp:4200 && npm run electron:serve-tsc && electron . --serve", "electron:local": "npm run build:prod && electron .", "electron:build": "npm run build:prod && electron-builder build --publish=never", + "electron:build:deb": "npm run electron:build -- --linux deb", + "electron:build:rpm": "npm run electron:build -- --linux rpm", + "electron:build:app": "npm run electron:build -- --linux AppImage", "test": "ng test --watch=false", "test:watch": "ng test", "lint": "ng lint" }, "dependencies": { - "@angular/animations": "17.3.2", - "@angular/cdk": "17.3.2", - "@angular/common": "17.3.2", - "@angular/compiler": "17.3.2", - "@angular/core": "17.3.2", - "@angular/forms": "17.3.2", - "@angular/platform-browser": "17.3.2", - "@angular/platform-browser-dynamic": "17.3.2", - "@angular/router": "17.3.2", - "@fontsource/roboto": "5.0.12", + "@angular/animations": "17.3.9", + "@angular/cdk": "17.3.9", + "@angular/common": "17.3.9", + "@angular/compiler": "17.3.9", + "@angular/core": "17.3.9", + "@angular/forms": "17.3.9", + "@angular/platform-browser": "17.3.9", + "@angular/platform-browser-dynamic": "17.3.9", + "@angular/router": "17.3.9", + "@fontsource/roboto": "5.0.13", "@mdi/font": "7.4.47", - "chart.js": "4.4.2", + "chart.js": "4.4.3", "chartjs-plugin-zoom": "2.0.1", "hotkeys-js": "3.13.7", "interactjs": "1.10.27", @@ -49,35 +52,34 @@ "panzoom": "9.4.3", "primeflex": "3.3.1", "primeicons": "7.0.0", - "primeng": "17.12.0", + "primeng": "17.17.0", "rxjs": "7.8.1", "tslib": "2.6.2", "uuid": "9.0.1", - "zone.js": "0.14.4" + "zone.js": "0.14.6" }, "devDependencies": { - "@angular-builders/custom-webpack": "17.0.1", - "@angular-devkit/build-angular": "17.3.2", - "@angular/cli": "17.3.2", - "@angular/compiler-cli": "17.3.2", - "@angular/language-service": "17.3.2", - "@types/leaflet": "1.9.8", - "@types/node": "20.12.2", + "@angular-builders/custom-webpack": "17.0.2", + "@angular-devkit/build-angular": "17.3.7", + "@angular/cli": "17.3.7", + "@angular/compiler-cli": "17.3.9", + "@angular/language-service": "17.3.9", + "@types/leaflet": "1.9.12", + "@types/node": "20.12.12", "@types/uuid": "9.0.8", - "electron": "29.1.6", + "electron": "30.0.6", "electron-builder": "24.13.3", - "electron-debug": "3.2.0", "node-polyfill-webpack-plugin": "3.0.0", "npm-run-all": "4.1.5", "ts-node": "10.9.2", - "typescript": "5.3.3", + "typescript": "5.4.5", "wait-on": "7.2.0" }, "overrides": { "axios": "1.6.2" }, "engines": { - "node": ">= 20.9.0" + "node": ">= 22.0.0" }, "browserslist": [ "chrome 114" diff --git a/desktop/rotator.png b/desktop/rotator.png new file mode 100644 index 000000000..14573a660 Binary files /dev/null and b/desktop/rotator.png differ diff --git a/desktop/settings.png b/desktop/settings.png index 706c39806..ee6099d48 100644 Binary files a/desktop/settings.png and b/desktop/settings.png differ diff --git a/desktop/src/app/alignment/alignment.component.html b/desktop/src/app/alignment/alignment.component.html index 98e3dacbd..765ccbfd7 100644 --- a/desktop/src/app/alignment/alignment.component.html +++ b/desktop/src/app/alignment/alignment.component.html @@ -1,49 +1,31 @@
- + @if (tab === 0) { - + } @else { - + }
-
- - {{ status | enum | lowercase }} - - - {{ darvDirection }} - - - {{ remainingTime | exposureTime }} - - - {{ progress | percent:'1.1-1' }} - - - - - {{ elapsedTime | exposureTime }} - - - {{ tppaRightAscension }} - - - {{ tppaDeclination }} - - +
+ + +
+ + +
@@ -58,22 +40,33 @@
-
+
- - + +
-
- East - +
+ + + +
-
+
+ + + + +
+
-
+
@@ -82,12 +75,12 @@
Azimuth {{ tppaAzimuthError }} - {{ tppaAzimuthErrorDirection }} + {{ tppaAzimuthErrorDirection }}
Altitude {{ tppaAltitudeError }} - {{ tppaAltitudeErrorDirection }} + {{ tppaAltitudeErrorDirection }}
Total @@ -96,14 +89,15 @@
+ @if (pausingOrPaused) { + + } @else if(!running) { - - + } +
- +
@@ -145,6 +139,17 @@ styleClass="ml-4" pTooltip="View image" tooltipPosition="bottom" size="small" />
+
+
+ 1. Locate a star in the Meridian and close to declination 0. + 2. Start DARV and wait for routine to complete. + 3. If you see V shaped track, adjust the Azimuth and repeat the step 2 till you get a line. + 4. Locate a star in the Eastern or Western horizon and close to declination 0. + 5. Start DARV and wait for routine to complete. + 6. If you see V shaped track, adjust the Altitude and repeat the step 5 till you get a line. + 7. Increase the drift time and repeat the step 1 to get a more accurate alignment. +
+
diff --git a/desktop/src/app/alignment/alignment.component.ts b/desktop/src/app/alignment/alignment.component.ts index d11200c64..922073481 100644 --- a/desktop/src/app/alignment/alignment.component.ts +++ b/desktop/src/app/alignment/alignment.component.ts @@ -1,14 +1,15 @@ -import { AfterViewInit, Component, HostListener, NgZone, OnDestroy } from '@angular/core' +import { AfterViewInit, Component, HostListener, NgZone, OnDestroy, ViewChild } from '@angular/core' +import { CameraExposureComponent } from '../../shared/components/camera-exposure/camera-exposure.component' import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' import { ElectronService } from '../../shared/services/electron.service' import { PreferenceService } from '../../shared/services/preference.service' import { AlignmentMethod, AlignmentPreference, DARVStart, DARVState, Hemisphere, TPPAStart, TPPAState } from '../../shared/types/alignment.types' import { Angle } from '../../shared/types/atlas.types' -import { Camera, EMPTY_CAMERA, EMPTY_CAMERA_START_CAPTURE, ExposureTimeUnit } from '../../shared/types/camera.types' +import { Camera, EMPTY_CAMERA, EMPTY_CAMERA_START_CAPTURE, ExposureTimeUnit, updateCameraStartCaptureFromCamera } from '../../shared/types/camera.types' import { EMPTY_GUIDE_OUTPUT, GuideDirection, GuideOutput } from '../../shared/types/guider.types' import { EMPTY_MOUNT, Mount } from '../../shared/types/mount.types' -import { DEFAULT_SOLVER_TYPES, EMPTY_PLATE_SOLVER_OPTIONS } from '../../shared/types/settings.types' +import { DEFAULT_SOLVER_TYPES, EMPTY_PLATE_SOLVER_PREFERENCE } from '../../shared/types/settings.types' import { deviceComparator } from '../../shared/utils/comparators' import { AppComponent } from '../app.component' import { CameraComponent } from '../camera/camera.component' @@ -32,31 +33,29 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { tab = 0 running = false + pausingOrPaused = false alignmentMethod?: AlignmentMethod status: DARVState | TPPAState = 'IDLE' - elapsedTime = 0 - remainingTime = 0 - progress = 0 - private id = '' readonly tppaRequest: TPPAStart = { capture: structuredClone(EMPTY_CAMERA_START_CAPTURE), - plateSolver: structuredClone(EMPTY_PLATE_SOLVER_OPTIONS), + plateSolver: structuredClone(EMPTY_PLATE_SOLVER_PREFERENCE), startFromCurrentPosition: true, - eastDirection: true, + stepDirection: 'EAST', compensateRefraction: true, stopTrackingWhenDone: true, - stepDistance: 10, + stepDuration: 5, } readonly plateSolverTypes = Array.from(DEFAULT_SOLVER_TYPES) + tppaFailed = false + tppaRightAscension: Angle = `00h00m00s` + tppaDeclination: Angle = `00°00'00"` tppaAzimuthError: Angle = `00°00'00"` tppaAzimuthErrorDirection = '' tppaAltitudeError: Angle = `00°00'00"` tppaAltitudeErrorDirection = '' tppaTotalError: Angle = `00°00'00"` - tppaRightAscension: Angle = '00h00m00s' - tppaDeclination: Angle = `00°00'00"` readonly darvRequest: DARVStart = { capture: structuredClone(EMPTY_CAMERA_START_CAPTURE), @@ -67,10 +66,14 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } readonly driftExposureUnit = ExposureTimeUnit.SECOND - readonly darvHemispheres: Hemisphere[] = ['NORTHERN', 'SOUTHERN'] darvHemisphere: Hemisphere = 'NORTHERN' darvDirection?: GuideDirection + @ViewChild('cameraExposure') + private readonly cameraExposure!: CameraExposureComponent + + private autoResizeTimeout?: any + constructor( app: AppComponent, private api: ApiService, @@ -172,50 +175,52 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { }) electron.on('TPPA.ELAPSED', event => { - if (event.id === this.id) { + if (event.camera.id === this.camera?.id) { ngZone.run(() => { - if (this.status !== 'PAUSING' || event.state === 'PAUSED') { - this.status = event.state - } - + this.status = event.state this.running = event.state !== 'FINISHED' - this.elapsedTime = event.elapsedTime + this.pausingOrPaused = event.state === 'PAUSING' || event.state === 'PAUSED' if (event.state === 'COMPUTED') { + this.tppaFailed = false + this.tppaRightAscension = event.rightAscension + this.tppaDeclination = event.declination this.tppaAzimuthError = event.azimuthError this.tppaAltitudeError = event.altitudeError this.tppaAzimuthErrorDirection = event.azimuthErrorDirection this.tppaAltitudeErrorDirection = event.altitudeErrorDirection this.tppaTotalError = event.totalError - } else if (event.state === 'SOLVED' || event.state === 'SLEWING') { + clearTimeout(this.autoResizeTimeout) + this.autoResizeTimeout = electron.autoResizeWindow() + } else if (event.state === 'FINISHED') { + this.cameraExposure.reset() + electron.autoResizeWindow() + } else if (event.state === 'SOLVED' || event.state === 'SLEWED') { + this.tppaFailed = false this.tppaRightAscension = event.rightAscension this.tppaDeclination = event.declination + } else if (event.state === 'FAILED') { + this.tppaFailed = true } - if (!this.running) { - this.alignmentMethod = undefined + if (event.capture && event.capture.state !== 'CAPTURE_FINISHED') { + this.cameraExposure.handleCameraCaptureEvent(event.capture, true) } }) } }) electron.on('DARV.ELAPSED', event => { - if (event.id === this.id) { + if (event.camera.id === this.camera?.id) { ngZone.run(() => { this.status = event.state - this.remainingTime = event.remainingTime - this.progress = event.progress - this.running = event.remainingTime > 0 + this.running = this.cameraExposure.handleCameraCaptureEvent(event.capture) if (event.state === 'FORWARD' || event.state === 'BACKWARD') { this.darvDirection = event.direction } else { this.darvDirection = undefined } - - if (!this.running) { - this.alignmentMethod = undefined - } }) } }) @@ -236,29 +241,30 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } async cameraChanged() { - if (this.camera.name) { - const camera = await this.api.camera(this.camera.name) + if (this.camera.id) { + const camera = await this.api.camera(this.camera.id) Object.assign(this.camera, camera) this.loadPreference() } } async mountChanged() { - if (this.mount.name) { - const mount = await this.api.mount(this.mount.name) + if (this.mount.id) { + const mount = await this.api.mount(this.mount.id) Object.assign(this.mount, mount) + this.tppaRequest.stepSpeed = mount.slewRate?.name } } async guideOutputChanged() { - if (this.guideOutput.name) { - const guideOutput = await this.api.guideOutput(this.guideOutput.name) + if (this.guideOutput.id) { + const guideOutput = await this.api.guideOutput(this.guideOutput.id) Object.assign(this.guideOutput, guideOutput) } } mountConnect() { - if (this.mount.name) { + if (this.mount.id) { if (this.mount.connected) { this.api.mountDisconnect(this.mount) } else { @@ -268,7 +274,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } guideOutputConnect() { - if (this.guideOutput.name) { + if (this.guideOutput.id) { if (this.guideOutput.connected) { this.api.guideOutputDisconnect(this.guideOutput) } else { @@ -278,7 +284,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } async showCameraDialog() { - if (this.camera.name) { + if (this.camera.id) { if (this.tab === 0) { if (await CameraComponent.showAsDialog(this.browserWindow, 'TPPA', this.camera, this.tppaRequest.capture)) { this.savePreference() @@ -295,7 +301,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } plateSolverChanged() { - this.tppaRequest.plateSolver = this.preference.plateSolverOptions(this.tppaRequest.plateSolver.type).get() + this.tppaRequest.plateSolver = this.preference.plateSolverPreference(this.tppaRequest.plateSolver.type).get() this.savePreference() } @@ -316,30 +322,29 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { this.darvRequest.capture.exposureTime = this.darvRequest.exposureTime * 1000000 this.darvRequest.capture.exposureDelay = this.darvRequest.initialPause await this.openCameraImage() - this.id = await this.api.darvStart(this.camera, this.guideOutput, this.darvRequest) + await this.api.darvStart(this.camera, this.guideOutput, this.darvRequest) } darvStop() { - this.api.darvStop(this.id) + this.api.darvStop(this.camera) } async tppaStart() { this.alignmentMethod = 'TPPA' await this.openCameraImage() - this.id = await this.api.tppaStart(this.camera, this.mount, this.tppaRequest) + await this.api.tppaStart(this.camera, this.mount, this.tppaRequest) } tppaPause() { - this.status = 'PAUSING' - this.api.tppaPause(this.id) + return this.api.tppaPause(this.camera) } tppaUnpause() { - this.api.tppaUnpause(this.id) + return this.api.tppaUnpause(this.camera) } tppaStop() { - this.api.tppaStop(this.id) + return this.api.tppaStop(this.camera) } openCameraImage() { @@ -350,18 +355,24 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { const preference = this.preference.alignmentPreference.get() this.tppaRequest.startFromCurrentPosition = preference.tppaStartFromCurrentPosition - this.tppaRequest.eastDirection = preference.tppaEastDirection + this.tppaRequest.stepDirection = preference.tppaStepDirection this.tppaRequest.compensateRefraction = preference.tppaCompensateRefraction this.tppaRequest.stopTrackingWhenDone = preference.tppaStopTrackingWhenDone - this.tppaRequest.stepDistance = preference.tppaStepDistance + this.tppaRequest.stepDuration = preference.tppaStepDuration this.tppaRequest.plateSolver.type = preference.tppaPlateSolverType this.darvRequest.initialPause = preference.darvInitialPause this.darvRequest.exposureTime = preference.darvExposureTime this.darvHemisphere = preference.darvHemisphere - if (this.camera.name) { - Object.assign(this.tppaRequest.capture, this.preference.cameraStartCaptureForTPPA(this.camera).get(this.tppaRequest.capture)) - Object.assign(this.darvRequest.capture, this.preference.cameraStartCaptureForDARV(this.camera).get(this.darvRequest.capture)) + if (this.camera.id) { + const cameraPreference = this.preference.cameraPreference(this.camera).get() + Object.assign(this.tppaRequest.capture, this.preference.cameraStartCaptureForTPPA(this.camera).get(cameraPreference)) + Object.assign(this.darvRequest.capture, this.preference.cameraStartCaptureForDARV(this.camera).get(cameraPreference)) + + if (this.camera.connected) { + updateCameraStartCaptureFromCamera(this.tppaRequest.capture, this.camera) + updateCameraStartCaptureFromCamera(this.darvRequest.capture, this.camera) + } } this.plateSolverChanged() @@ -376,10 +387,10 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { const preference: AlignmentPreference = { tppaStartFromCurrentPosition: this.tppaRequest.startFromCurrentPosition, - tppaEastDirection: this.tppaRequest.eastDirection, + tppaStepDirection: this.tppaRequest.stepDirection, tppaCompensateRefraction: this.tppaRequest.compensateRefraction, tppaStopTrackingWhenDone: this.tppaRequest.stopTrackingWhenDone, - tppaStepDistance: this.tppaRequest.stepDistance, + tppaStepDuration: this.tppaRequest.stepDuration, tppaPlateSolverType: this.tppaRequest.plateSolver.type, darvInitialPause: this.darvRequest.initialPause, darvExposureTime: this.darvRequest.exposureTime, diff --git a/desktop/src/app/app-routing.module.ts b/desktop/src/app/app-routing.module.ts index 989fc38bb..bdd1b6a47 100644 --- a/desktop/src/app/app-routing.module.ts +++ b/desktop/src/app/app-routing.module.ts @@ -16,6 +16,7 @@ import { HomeComponent } from './home/home.component' import { ImageComponent } from './image/image.component' import { INDIComponent } from './indi/indi.component' import { MountComponent } from './mount/mount.component' +import { RotatorComponent } from './rotator/rotator.component' import { SequencerComponent } from './sequencer/sequencer.component' import { SettingsComponent } from './settings/settings.component' @@ -45,6 +46,10 @@ const routes: Routes = [ path: 'mount', component: MountComponent, }, + { + path: 'rotator', + component: RotatorComponent, + }, { path: 'guider', component: GuiderComponent, diff --git a/desktop/src/app/app.component.html b/desktop/src/app/app.component.html index fef76d594..d6c47560e 100644 --- a/desktop/src/app/app.component.html +++ b/desktop/src/app/app.component.html @@ -1,4 +1,4 @@ -
{{ title }}
diff --git a/desktop/src/app/app.component.scss b/desktop/src/app/app.component.scss index 011a140d7..67a9efabd 100644 --- a/desktop/src/app/app.component.scss +++ b/desktop/src/app/app.component.scss @@ -24,4 +24,9 @@ width: 16px; } } + + #main { + margin-left: 2px; + margin-right: 2px; + } } \ No newline at end of file diff --git a/desktop/src/app/app.component.ts b/desktop/src/app/app.component.ts index 0ece200c6..a9058300f 100644 --- a/desktop/src/app/app.component.ts +++ b/desktop/src/app/app.component.ts @@ -22,6 +22,7 @@ export class AppComponent implements AfterViewInit { subTitle? = '' backgroundColor = '#212121' topMenu: ExtendedMenuItem[] = [] + showTopBar = true get title() { return this.windowTitle.getTitle() diff --git a/desktop/src/app/app.module.ts b/desktop/src/app/app.module.ts index 423d76b89..74dad403c 100644 --- a/desktop/src/app/app.module.ts +++ b/desktop/src/app/app.module.ts @@ -5,6 +5,7 @@ import { LOCALE_ID, NgModule } from '@angular/core' import { FormsModule } from '@angular/forms' import { BrowserModule } from '@angular/platform-browser' import { BrowserAnimationsModule } from '@angular/platform-browser/animations' +import { AccordionModule } from 'primeng/accordion' import { ConfirmationService, MessageService } from 'primeng/api' import { BadgeModule } from 'primeng/badge' import { ButtonModule } from 'primeng/button' @@ -39,6 +40,7 @@ import { TagModule } from 'primeng/tag' import { TieredMenuModule } from 'primeng/tieredmenu' import { ToastModule } from 'primeng/toast' import { TooltipModule } from 'primeng/tooltip' +import { TreeModule } from 'primeng/tree' import { CameraExposureComponent } from '../shared/components/camera-exposure/camera-exposure.component' import { DeviceListButtonComponent } from '../shared/components/device-list-button/device-list-button.component' import { DeviceListMenuComponent } from '../shared/components/device-list-menu/device-list-menu.component' @@ -47,6 +49,7 @@ import { HistogramComponent } from '../shared/components/histogram/histogram.com import { MapComponent } from '../shared/components/map/map.component' import { MenuItemComponent } from '../shared/components/menu-item/menu-item.component' import { MoonComponent } from '../shared/components/moon/moon.component' +import { SlideMenuComponent } from '../shared/components/slide-menu/slide-menu.component' import { LocationDialog } from '../shared/dialogs/location/location.dialog' import { NoDropdownDirective } from '../shared/directives/no-dropdown.directive' import { StopPropagationDirective } from '../shared/directives/stop-propagation.directive' @@ -76,6 +79,7 @@ import { ImageComponent } from './image/image.component' import { INDIComponent } from './indi/indi.component' import { INDIPropertyComponent } from './indi/property/indi-property.component' import { MountComponent } from './mount/mount.component' +import { RotatorComponent } from './rotator/rotator.component' import { SequencerComponent } from './sequencer/sequencer.component' import { SettingsComponent } from './settings/settings.component' @@ -108,18 +112,21 @@ import { SettingsComponent } from './settings/settings.component' INDIComponent, INDIPropertyComponent, LocationDialog, + MapComponent, MenuItemComponent, MoonComponent, MountComponent, NoDropdownDirective, - MapComponent, + RotatorComponent, SequencerComponent, SettingsComponent, SkyObjectPipe, + SlideMenuComponent, StopPropagationDirective, WinPipe, ], imports: [ + AccordionModule, AppRoutingModule, BadgeModule, BrowserAnimationsModule, @@ -160,6 +167,7 @@ import { SettingsComponent } from './settings/settings.component' TieredMenuModule, ToastModule, TooltipModule, + TreeModule, ], providers: [ AnglePipe, diff --git a/desktop/src/app/atlas/atlas.component.html b/desktop/src/app/atlas/atlas.component.html index 7b16f9fe8..b8ab9cc80 100644 --- a/desktop/src/app/atlas/atlas.component.html +++ b/desktop/src/app/atlas/atlas.component.html @@ -441,5 +441,5 @@ - + \ No newline at end of file diff --git a/desktop/src/app/atlas/atlas.component.scss b/desktop/src/app/atlas/atlas.component.scss index 5f6c0f61e..99334642c 100644 --- a/desktop/src/app/atlas/atlas.component.scss +++ b/desktop/src/app/atlas/atlas.component.scss @@ -5,9 +5,6 @@ ::ng-deep { .p-tabview { - padding-left: 0.21rem; - padding-right: 0.21rem; - p-table.planet .p-datatable-wrapper { height: 229px; } diff --git a/desktop/src/app/atlas/atlas.component.ts b/desktop/src/app/atlas/atlas.component.ts index b6b430f46..4e95d9c85 100644 --- a/desktop/src/app/atlas/atlas.component.ts +++ b/desktop/src/app/atlas/atlas.component.ts @@ -3,12 +3,12 @@ import { ActivatedRoute } from '@angular/router' import { Chart, ChartData, ChartOptions } from 'chart.js' import zoomPlugin from 'chartjs-plugin-zoom' import moment from 'moment' -import { MenuItem } from 'primeng/api' import { UIChart } from 'primeng/chart' import { ListboxChangeEvent } from 'primeng/listbox' import { OverlayPanel } from 'primeng/overlaypanel' import { Subscription, timer } from 'rxjs' import { DeviceListMenuComponent } from '../../shared/components/device-list-menu/device-list-menu.component' +import { ExtendedMenuItem } from '../../shared/components/menu-item/menu-item.component' import { ONE_DECIMAL_PLACE_FORMATTER, TWO_DIGITS_FORMATTER } from '../../shared/constants' import { SkyObjectPipe } from '../../shared/pipes/skyObject.pipe' import { ApiService } from '../../shared/services/api.service' @@ -152,7 +152,7 @@ export class AtlasComponent implements OnInit, AfterContentInit, AfterViewInit, readonly satelliteSearchGroup = new Map() name? = 'Sun' - tags: { title: string, severity: string }[] = [] + tags: { title: string, severity: 'success' | 'info' | 'warning' | 'danger' }[] = [] @ViewChild('imageOfSun') private readonly imageOfSun!: ElementRef @@ -406,7 +406,7 @@ export class AtlasComponent implements OnInit, AfterContentInit, AfterViewInit, 'ONEWEB', 'SCIENCE', 'STARLINK', 'STATIONS', 'VISUAL' ] - readonly ephemerisModel: MenuItem[] = [ + readonly ephemerisModel: ExtendedMenuItem[] = [ { icon: 'mdi mdi-magnify', label: 'Find sky objects around this object', @@ -519,7 +519,7 @@ export class AtlasComponent implements OnInit, AfterContentInit, AfterViewInit, if (this.tab === SkyAtlasTab.SKY_OBJECT) { this.skyObjectFilter.rightAscension = data.filter?.rightAscension || this.skyObjectFilter.rightAscension this.skyObjectFilter.declination = data.filter?.declination || this.skyObjectFilter.declination - if (data.filter?.radius) this.skyObjectFilter.radius = data.filter?.radius || this.skyObjectFilter.radius + this.skyObjectFilter.radius = data.filter?.radius || this.skyObjectFilter.radius || 4.0 this.skyObjectFilter.constellation = data.filter?.constellation || this.skyObjectFilter.constellation this.skyObjectFilter.magnitude = data.filter?.magnitude || this.skyObjectFilter.magnitude this.skyObjectFilter.type = data.filter?.type || this.skyObjectFilter.type @@ -895,7 +895,7 @@ export class AtlasComponent implements OnInit, AfterContentInit, AfterViewInit, } else { const mount = await this.deviceMenu.show(mounts) - if (mount && mount.connected) { + if (mount && mount !== 'NONE' && mount.connected) { action(mount) return true } diff --git a/desktop/src/app/calibration/calibration.component.html b/desktop/src/app/calibration/calibration.component.html index 7b4ebf8ee..acfded5e3 100644 --- a/desktop/src/app/calibration/calibration.component.html +++ b/desktop/src/app/calibration/calibration.component.html @@ -1,98 +1,64 @@
-
-

Groups

+
+
- - - - Type - # - Filter - Duration - Size - Bin - T. (°C) - Gain - - - - - {{ item.key.type }} - {{ item.frames.length }} - {{ item.key.filter ?? '' }} - {{ item.key.exposureTime | exposureTime }} - {{ item.key.width }}x{{ item.key.height }} - {{ item.key.binX }}x{{ item.key.binY }} - {{ item.key.temperature }} - {{ item.key.gain }} - + + +
+ @if (node.data.type === 'NAME') { + {{ node.label }} + } @else if (node.data.type === 'GROUP') { +
+ + + + + + + +
+ } @else if (node.data.type === 'FRAME') { +
+ + {{ node.data.data.path }} +
+ } +
+ @if (node.data.type === 'NAME') { + + + + } + +
+
-
-
-
-

Frames

- -
-
- - - - - - - Type - Filter - Duration - Size - Bin - T. (°C) - Gain - - - - - - - - {{ item.type }} - {{ item.filter ?? '' }} - {{ item.exposureTime | exposureTime }} - {{ item.width }}x{{ item.height }} - {{ item.binX }}x{{ item.binY }} - {{ item.temperature }} - {{ item.gain }} - - - +
+
+
+ + +
-
-
- - - - -
-
- - - -
-
+ + + +
-
\ No newline at end of file + + + + \ No newline at end of file diff --git a/desktop/src/app/calibration/calibration.component.scss b/desktop/src/app/calibration/calibration.component.scss index e69de29bb..83a651dd7 100644 --- a/desktop/src/app/calibration/calibration.component.scss +++ b/desktop/src/app/calibration/calibration.component.scss @@ -0,0 +1,33 @@ +:host { + ::ng-deep { + .p-treenode-label { + width: 100%; + } + + .p-tree-wrapper { + max-height: 288px; + } + + .p-tree { + .p-tree-container { + padding-right: 4px; + + .p-treenode { + padding: 0; + + .p-treenode-content { + padding: 0 0.5rem; + } + } + } + } + + .p-treenode-leaf>.p-treenode-content .p-tree-toggler { + display: none; + } + + .p-tree-empty-message { + padding: 1rem 0.5rem; + } + } +} \ No newline at end of file diff --git a/desktop/src/app/calibration/calibration.component.ts b/desktop/src/app/calibration/calibration.component.ts index 18ed705b2..eb50c15e9 100644 --- a/desktop/src/app/calibration/calibration.component.ts +++ b/desktop/src/app/calibration/calibration.component.ts @@ -1,170 +1,271 @@ -import { AfterViewInit, Component, HostListener, NgZone, OnDestroy } from '@angular/core' -import { ActivatedRoute } from '@angular/router' -import path from 'path' -import { CheckboxChangeEvent } from 'primeng/checkbox' +import { AfterViewInit, Component, HostListener, OnDestroy } from '@angular/core' +import { dirname } from 'path' +import { TreeDragDropService, TreeNode } from 'primeng/api' +import { TreeNodeDropEvent } from 'primeng/tree' import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' import { ElectronService } from '../../shared/services/electron.service' -import { LocalStorageService } from '../../shared/services/local-storage.service' +import { PreferenceService } from '../../shared/services/preference.service' import { CalibrationFrame, CalibrationFrameGroup } from '../../shared/types/calibration.types' -import { Camera } from '../../shared/types/camera.types' import { AppComponent } from '../app.component' -export const CALIBRATION_DIR_KEY = 'calibration.directory' +export interface CalibrationNode extends TreeNode { + key: string + label: string + data: TreeNodeData + children: CalibrationNode[] + parent?: CalibrationNode +} + +export type TreeNodeData = + { type: 'NAME', data: string } | + { type: 'GROUP', data: CalibrationFrameGroup } | + { type: 'FRAME', data: CalibrationFrame } @Component({ selector: 'app-calibration', templateUrl: './calibration.component.html', styleUrls: ['./calibration.component.scss'], + providers: [TreeDragDropService], }) export class CalibrationComponent implements AfterViewInit, OnDestroy { - camera!: Camera - - groups: CalibrationFrameGroup[] = [] - group?: CalibrationFrameGroup - frame?: CalibrationFrame + readonly frames: CalibrationNode[] = [] - get groupIsEnabled() { - return !!this.group && !this.group.frames.find(e => !e.enabled) - } + showNewGroupDialog = false + newGroupName = '' + newGroupDialogSave: () => void = () => { } constructor( - private app: AppComponent, + app: AppComponent, private api: ApiService, - electron: ElectronService, + private electron: ElectronService, private browserWindow: BrowserWindowService, - private route: ActivatedRoute, - private storage: LocalStorageService, - ngZone: NgZone, + private preference: PreferenceService, ) { app.title = 'Calibration' - - app.topMenu.push({ - icon: 'mdi mdi-image-plus', - tooltip: 'Add file', - command: async () => { - const defaultPath = this.storage.get(CALIBRATION_DIR_KEY, '') - const filePath = await electron.openFits({ defaultPath }) - - if (filePath) { - this.storage.set(CALIBRATION_DIR_KEY, path.dirname(filePath)) - this.upload(filePath) - } - }, - }) - - app.topMenu.push({ - icon: 'mdi mdi-folder-plus', - tooltip: 'Add folder', - command: async () => { - const defaultPath = this.storage.get(CALIBRATION_DIR_KEY, '') - const dirPath = await electron.openDirectory({ defaultPath }) - - if (dirPath) { - this.storage.set(CALIBRATION_DIR_KEY, dirPath) - this.upload(dirPath) - } - }, - }) - - electron.on('DATA.CHANGED', (data: Camera) => { - ngZone.run(() => { - if (data.name !== this.camera.name) { - this.loadForCamera(data, true) - } - }) - }) } - async ngAfterViewInit() { - this.route.queryParams.subscribe(async e => { - const camera = JSON.parse(decodeURIComponent(e.data)) as Camera - this.loadForCamera(camera) - }) + ngAfterViewInit() { + this.load() } @HostListener('window:unload') ngOnDestroy() { } - private loadForCamera(camera: Camera, reload: boolean = false) { - this.camera = camera - this.app.subTitle = this.camera.name - return reload ? this.reload() : this.load() + private makeTreeNode(key: string, label: string, data: TreeNodeData, parent?: CalibrationNode): CalibrationNode { + const draggable = data.type === 'FRAME' + const droppable = data.type === 'NAME' + return { key, label, data, children: [], parent, draggable, droppable } } - private async upload(path: string) { - const frames = await this.api.uploadCalibrationFrame(this.camera!, path) + addGroup(name: string) { + const node = this.frames.find(e => e.label === name) + ?? this.makeTreeNode(`group-${name}`, name, { type: 'NAME', data: name }) - if (frames.length > 0) { - this.load() + if (this.frames.indexOf(node) < 0) { + this.frames.push(node) } + + return node } - private async load() { - this.groups = await this.api.calibrationFrames(this.camera) + addFrameGroup(name: string | CalibrationNode, group: CalibrationFrameGroup) { + const parent = typeof name === 'string' + ? this.frames.find(e => e.label === name) + : name + + if (parent) { + const node = this.makeTreeNode(`frame-group-${group.id}`, `Frame`, { type: 'GROUP', data: group }, parent) + parent.children.push(node) + return node + } + + return undefined } - private async reload() { - this.group = undefined - this.groupSelected() - this.load() + addFrame(group: string | CalibrationNode, frame: CalibrationFrame) { + const parent = typeof group === 'string' + ? this.frames.find(e => e.label === group) + : group + + if (parent) { + const node = this.makeTreeNode(`frame-${frame.id}`, `Frame`, { type: 'FRAME', data: frame }, parent) + parent.children.push(node) + return node + } + + return undefined } - groupSelected() { - this.frame = undefined + async openFileToUpload(node: CalibrationNode) { + if (node.data.type === 'NAME') { + const preference = this.preference.calibrationPreference.get() + const path = await this.electron.openImage({ defaultPath: preference.openPath }) + + if (path) { + preference.openPath = dirname(path) + this.preference.calibrationPreference.set(preference) + this.upload(node, path) + } + } } - groupChecked(event: CheckboxChangeEvent) { - this.group?.frames?.forEach(e => e.enabled = event.checked) + async openDirectoryToUpload(node: CalibrationNode) { + if (node.data.type === 'NAME') { + const preference = this.preference.calibrationPreference.get() + const path = await this.electron.openDirectory({ defaultPath: preference.openPath }) + + if (path) { + preference.openPath = path + this.preference.calibrationPreference.set(preference) + this.upload(node, path) + } + } } - async frameChecked(frame: CalibrationFrame, event: CheckboxChangeEvent) { - await this.api.editCalibrationFrame(frame) + private async upload(node: CalibrationNode, path: string) { + if (node.data.type === 'NAME') { + const frames = await this.api.uploadCalibrationFrame(node.data.data, path) + + if (frames.length > 0) { + this.electron.calibrationChanged() + this.load() + } + } + } + + private async load() { + this.frames.length = 0 + + const names = await this.api.calibrationGroups() + + for (const name of names) { + const nameNode = this.addGroup(name) + + const groups = await this.api.calibrationFrames(name) + + for (const group of groups) { + const frameGroupNode = this.addFrameGroup(nameNode, group)! + + for (const frame of group.frames) { + this.addFrame(frameGroupNode, frame) + } + } + } } openImage(frame: CalibrationFrame) { this.browserWindow.openImage({ path: frame.path }) } - replaceFrame(frame: CalibrationFrame) { - console.info(frame) + toggleCalibrationFrame(node: CalibrationNode, enabled: boolean) { + if (node.data.type === 'FRAME') { + this.api.editCalibrationFrame(node.data.data) + } } - async deleteFrame(frame: CalibrationFrame) { - await this.api.deleteCalibrationFrame(frame) + async deleteFrame(node: CalibrationNode) { + const deleteFromParent = async () => { + if (node.parent) { + const idx = node.parent.children.indexOf(node) - if (this.frame === frame) { - this.frame = undefined + if (idx >= 0) { + node.parent.children.splice(idx, 1) + console.info('frame deleted', node) + } + + if (!node.parent.children.length) { + await this.deleteFrame(node.parent) + } + } else { + const idx = this.frames.indexOf(node) + + if (idx >= 0) { + this.frames.splice(idx, 1) + console.info('frame deleted', node) + this.electron.calibrationChanged() + } + } } - let index = this.group?.frames?.findIndex(e => e.id === frame.id) ?? -1 + if (node.data.type === 'FRAME') { + await this.api.deleteCalibrationFrame(node.data.data) + await deleteFromParent() + } else { + for (const frame of Array.from(node.children)) { + await this.deleteFrame(frame) + } - if (index >= 0) { - this.group!.frames.splice(index, 1) + if (!node.children.length) { + await deleteFromParent() + } + } + } - if (!this.group!.frames.length) { - index = this.groups.indexOf(this.group!) + private calibrationFrameFromNode(node: CalibrationNode) { + const frames: CalibrationFrame[] = [] - if (index >= 0) { - this.groups.splice(index, 1) - this.group = undefined + function recursive(node: TreeNode) { + if (node.data!.type === 'NAME' || node.data!.type === 'GROUP') { + for (const child of node.children!) { + recursive(child) } + } else { + frames.push(node.data!.data) } } + + recursive(node) + + return frames + } + + showNewGroupDialogForAdd() { + this.newGroupDialogSave = () => { + this.addGroup(this.newGroupName) + this.showNewGroupDialog = false + } + + this.newGroupName = '' + this.showNewGroupDialog = true } - async deleteGroupFrames(group: CalibrationFrameGroup) { - for (const frame of group.frames) { - await this.api.deleteCalibrationFrame(frame) + showNewGroupDialogForEdit(node: CalibrationNode) { + if (node.data.type === 'NAME') { + this.newGroupDialogSave = async () => { + const frames = this.calibrationFrameFromNode(node) - if (frame === this.frame) { - this.frame = undefined + for (const frame of frames) { + frame.name = this.newGroupName + await this.api.editCalibrationFrame(frame) + this.electron.calibrationChanged() + } + + this.showNewGroupDialog = false + this.load() } + + this.newGroupName = node.data.data + this.showNewGroupDialog = true } + } - if (group === this.group) { - this.group === undefined + editGroupName() { + this.showNewGroupDialog = false + } + + async frameDropped(event: TreeNodeDropEvent) { + const dragNode = event.dragNode as CalibrationNode + const dropNode = event.dropNode as CalibrationNode + + if (dragNode.data.type === 'FRAME' && dropNode.data.type === 'NAME' && + dragNode.data.data.name !== dropNode.data.data + ) { + dragNode.data.data.name = dropNode.data.data + await this.api.editCalibrationFrame(dragNode.data.data) + this.electron.calibrationChanged() + this.load() } } } \ No newline at end of file diff --git a/desktop/src/app/camera/camera.component.html b/desktop/src/app/camera/camera.component.html index 0c4d324ec..0bac743f4 100644 --- a/desktop/src/app/camera/camera.component.html +++ b/desktop/src/app/camera/camera.component.html @@ -13,8 +13,8 @@
- +
@@ -46,12 +46,12 @@
- Cooler ({{ camera.coolerPower | number: '1.1-1' }}%) + Cooler ({{ camera.coolerPower | number: '1.1-1' }}%)
- Dew heater + Dew heater
@@ -92,7 +92,7 @@
- Exposure Mode + Exposure Mode
@@ -149,7 +149,7 @@
- Subframe + Subframe
@@ -200,27 +200,10 @@
-
- @if (equipment.mount?.id || equipment.wheel?.id || equipment.focuser?.id) { - - {{ equipment.mount?.name }} - - - {{ equipment.wheel?.name }} - - - {{ equipment.focuser?.name }} - - } @else { - - No devices to snoop - - } -
+ severity="success" size="small" [pTooltip]="startTooltip" tooltipPosition="top" [escape]="false" + tooltipStyleClass="min-w-22rem flex justify-content-center" />
- + +
-
- Enabled +
+ Enabled
+
+ RA only + +
Dither (px)
-
- Dither in RA only - -
-
+
{ + if (event.device.id === this.equipment.mount?.id) { + ngZone.run(() => { + Object.assign(this.equipment.mount!, event.device) + }) + } + }) + + electron.on('WHEEL.UPDATED', event => { + if (event.device.id === this.equipment.wheel?.id) { + ngZone.run(() => { + Object.assign(this.equipment.wheel!, event.device) + }) + } + }) + + electron.on('FOCUSER.UPDATED', event => { + if (event.device.id === this.equipment.focuser?.id) { + ngZone.run(() => { + Object.assign(this.equipment.focuser!, event.device) + }) + } + }) + + electron.on('ROTATOR.UPDATED', event => { + if (event.device.id === this.equipment.rotator?.id) { + ngZone.run(() => { + Object.assign(this.equipment.rotator!, event.device) + }) + } + }) + + electron.on('CALIBRATION.CHANGED', () => { + ngZone.run(() => this.loadCalibrationGroups()) + }) + this.cameraModel[1].visible = !app.modal } @@ -203,6 +251,8 @@ export class CameraComponent implements AfterContentInit, OnDestroy { if (!this.app.modal) { this.loadEquipment() } + + this.loadCalibrationGroups() } @HostListener('window:unload') @@ -237,8 +287,8 @@ export class CameraComponent implements AfterContentInit, OnDestroy { } async cameraChanged(camera?: Camera) { - if (camera && camera.name) { - camera = await this.api.camera(camera.name) + if (camera && camera.id) { + camera = await this.api.camera(camera.id) Object.assign(this.camera, camera) this.loadPreference() @@ -254,73 +304,129 @@ export class CameraComponent implements AfterContentInit, OnDestroy { } private async loadEquipment() { - const makeMountItem = (mount?: Mount) => { - return { - icon: mount ? 'mdi mdi-connection' : 'mdi mdi-close', - label: mount?.name ?? 'None', - command: () => { - this.equipment.mount = mount + const buildStartTooltip = () => { + this.startTooltip = + `MOUNT: ${this.equipment.mount?.name ?? 'None'} + FILTER WHEEL: ${this.equipment.wheel?.name ?? 'None'} + FOCUSER: ${this.equipment.focuser?.name ?? 'None'} + ROTATOR: ${this.equipment.rotator?.name ?? 'None'}` + } + + const makeItem = (checked: boolean, command: () => void, device?: Device) => { + return { + icon: device ? 'mdi mdi-connection' : 'mdi mdi-close', + label: device?.name ?? 'None', + checked, + command: async (event: SlideMenuItemCommandEvent) => { + command() + buildStartTooltip() this.preference.equipmentForDevice(this.camera).set(this.equipment) - this.electron.autoResizeWindow() + event.parent?.menu?.forEach(item => item.checked = item === event.item) }, } } + // MOUNT + const mounts = await this.api.mounts() + this.equipment.mount = mounts.find(e => e.name === this.equipment.mount?.name) + + const makeMountItem = (mount?: Mount) => { + return makeItem(this.equipment.mount?.name === mount?.name, () => this.equipment.mount = mount, mount) + } - this.cameraModel[1].items![0].items!.push(makeMountItem()) + this.cameraModel[1].menu![0].menu!.push(makeMountItem()) for (const mount of mounts) { - this.cameraModel[1].items![0].items!.push(makeMountItem(mount)) + this.cameraModel[1].menu![0].menu!.push(makeMountItem(mount)) } - this.equipment.mount = mounts.find(e => e.name === this.equipment.mount?.name) + // FILTER WHEEL + + const wheels = await this.api.wheels() + this.equipment.wheel = wheels.find(e => e.name === this.equipment.wheel?.name) const makeWheelItem = (wheel?: FilterWheel) => { - return { - icon: wheel ? 'mdi mdi-connection' : 'mdi mdi-close', - label: wheel?.name ?? 'None', - command: () => { - this.equipment.wheel = wheel - this.preference.equipmentForDevice(this.camera).set(this.equipment) - this.electron.autoResizeWindow() - }, - } + return makeItem(this.equipment.wheel?.name === wheel?.name, () => this.equipment.wheel = wheel, wheel) } - const wheels = await this.api.wheels() - - this.cameraModel[1].items![1].items!.push(makeWheelItem()) + this.cameraModel[1].menu![1].menu!.push(makeWheelItem()) for (const wheel of wheels) { - this.cameraModel[1].items![1].items!.push(makeWheelItem(wheel)) + this.cameraModel[1].menu![1].menu!.push(makeWheelItem(wheel)) } - this.equipment.wheel = wheels.find(e => e.name === this.equipment.wheel?.name) + // FOCUSER + + const focusers = await this.api.focusers() + this.equipment.focuser = focusers.find(e => e.name === this.equipment.focuser?.name) const makeFocuserItem = (focuser?: Focuser) => { - return { - icon: focuser ? 'mdi mdi-connection' : 'mdi mdi-close', - label: focuser?.name ?? 'None', - command: () => { - this.equipment.focuser = focuser - this.preference.equipmentForDevice(this.camera).set(this.equipment) - this.electron.autoResizeWindow() + return makeItem(this.equipment.focuser?.name === focuser?.name, () => this.equipment.focuser = focuser, focuser) + } + + this.cameraModel[1].menu![2].menu!.push(makeFocuserItem()) + + for (const focuser of focusers) { + this.cameraModel[1].menu![2].menu!.push(makeFocuserItem(focuser)) + } + + // ROTATOR + + const rotators = await this.api.rotators() + this.equipment.rotator = rotators.find(e => e.name === this.equipment.rotator?.name) + + const makeRotatorItem = (rotator?: Rotator) => { + return makeItem(this.equipment.rotator?.name === rotator?.name, () => this.equipment.rotator = rotator, rotator) + } + + this.cameraModel[1].menu![3].menu!.push(makeRotatorItem()) + + for (const rotator of rotators) { + this.cameraModel[1].menu![3].menu!.push(makeRotatorItem(rotator)) + } + + buildStartTooltip() + } + + private async loadCalibrationGroups() { + const groups = await this.api.calibrationGroups() + const found = !!groups.find(e => this.request.calibrationGroup === e) + + if (!found) { + this.request.calibrationGroup = undefined + } + + const makeItem = (name?: string) => { + return { + label: name ?? 'None', + icon: name ? 'mdi mdi-wrench' : 'mdi mdi-close', + checked: this.request.calibrationGroup === name, + command: (event: SlideMenuItemCommandEvent) => { + this.request.calibrationGroup = name + this.loadCalibrationGroups() }, } } - const focusers = await this.api.focusers() + const menu: ExtendedMenuItem[] = [] - this.cameraModel[1].items![2].items!.push(makeFocuserItem()) + menu.push({ + icon: 'mdi mdi-wrench', + label: 'Open Calibration', + command: () => { + return this.browserWindow.openCalibration({ bringToFront: true }) + }, + }) - for (const focuser of focusers) { - this.cameraModel[1].items![2].items!.push(makeFocuserItem(focuser)) - } + menu.push(SEPARATOR_MENU_ITEM) + menu.push(makeItem()) - this.equipment.focuser = focusers.find(e => e.name === this.equipment.focuser?.name) + for (const group of groups) { + menu.push(makeItem(group)) + } - this.electron.autoResizeWindow() + this.calibrationModel = menu } connect() { @@ -379,11 +485,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { } openCameraImage() { - return this.browserWindow.openCameraImage(this.camera) - } - - openCameraCalibration() { - return this.browserWindow.openCalibration({ data: this.camera, bringToFront: true }) + return this.browserWindow.openCameraImage(this.camera, 'CAMERA', this.request) } private makeCameraStartCapture(): CameraStartCapture { @@ -406,7 +508,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { async startCapture() { await this.openCameraImage() - await this.api.cameraSnoop(this.camera, this.equipment.mount, this.equipment.wheel, this.equipment.focuser) + await this.api.cameraSnoop(this.camera, this.equipment) await this.api.cameraStartCapture(this.camera, this.makeCameraStartCapture()) this.preference.equipmentForDevice(this.camera).set(this.equipment) } @@ -465,7 +567,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { } private update() { - if (this.camera.name) { + if (this.camera.id) { if (this.camera.connected) { updateCameraStartCaptureFromCamera(this.request, this.camera) this.updateExposureUnit(this.exposureTimeUnit) @@ -514,7 +616,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { this.request.dither!.amount = preference.dither?.amount ?? 1.5 this.request.dither!.afterExposures = preference.dither?.afterExposures ?? 1 - this.equipment = this.preference.equipmentForDevice(this.camera).get() + Object.assign(this.equipment, this.preference.equipmentForDevice(this.camera).get()) } } @@ -526,6 +628,7 @@ export class CameraComponent implements AfterContentInit, OnDestroy { exposureTimeUnit: this.exposureTimeUnit, exposureMode: this.exposureMode, subFrame: this.subFrame, + savePath: this.request.savePath || this.savePath, } this.preference.cameraPreference(this.camera).set(preference) diff --git a/desktop/src/app/filterwheel/filterwheel.component.html b/desktop/src/app/filterwheel/filterwheel.component.html index 70d651311..d9f6693d2 100644 --- a/desktop/src/app/filterwheel/filterwheel.component.html +++ b/desktop/src/app/filterwheel/filterwheel.component.html @@ -64,7 +64,7 @@
+ (deviceChange)="focuserChanged()" style="max-width: 60%" />
- + - +
-
+
+ {{ + savedPath }}
+
+ + + + + +
+ styleClass="p-inputtext-sm border-0" [allowEmpty]="false" (ngModelChange)="savePreference()" />
@@ -27,7 +39,7 @@ + styleClass="p-inputtext-sm border-0" [allowEmpty]="false" (ngModelChange)="savePreference()" />
@@ -35,7 +47,7 @@ + styleClass="p-inputtext-sm border-0" [allowEmpty]="false" (ngModelChange)="savePreference()" />
@@ -43,14 +55,14 @@ + styleClass="p-inputtext-sm border-0" [allowEmpty]="false" (ngModelChange)="savePreference()" />
diff --git a/desktop/src/app/flat-wizard/flat-wizard.component.ts b/desktop/src/app/flat-wizard/flat-wizard.component.ts index 71bc72924..65ef1982e 100644 --- a/desktop/src/app/flat-wizard/flat-wizard.component.ts +++ b/desktop/src/app/flat-wizard/flat-wizard.component.ts @@ -26,6 +26,7 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { wheel = structuredClone(EMPTY_WHEEL) running = false + savedPath?: string @ViewChild('cameraExposure') private readonly cameraExposure!: CameraExposureComponent @@ -36,7 +37,7 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { private readonly selectedFiltersMap = new Map() readonly request: FlatWizardRequest = { - captureRequest: structuredClone(EMPTY_CAMERA_START_CAPTURE), + capture: structuredClone(EMPTY_CAMERA_START_CAPTURE), exposureMin: 1, exposureMax: 2000, meanTarget: 32768, @@ -54,7 +55,7 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { constructor( app: AppComponent, private api: ApiService, - electron: ElectronService, + private electron: ElectronService, private browserWindow: BrowserWindowService, private prime: PrimeService, private preference: PreferenceService, @@ -63,27 +64,22 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { app.title = 'Flat Wizard' electron.on('FLAT_WIZARD.ELAPSED', event => { - if (event.state === 'EXPOSURING' && event.capture && event.capture.camera?.id === this.camera?.id) { - ngZone.run(() => { - this.running = this.cameraExposure.handleCameraCaptureEvent(event.capture!, true) - }) - } else if (event.state === 'CAPTURED') { - ngZone.run(() => { + ngZone.run(() => { + if (event.state === 'EXPOSURING' && event.capture && event.capture.camera?.id === this.camera?.id) { + this.running = true + this.cameraExposure.handleCameraCaptureEvent(event.capture!, true) + } else if (event.state === 'CAPTURED') { this.running = false - this.prime.message(`Flat frame saved at ${event.savedPath}`) - }) - } else if (event.state === 'FAILED') { - ngZone.run(() => { + this.savedPath = event.savedPath + this.electron.autoResizeWindow() + this.prime.message(`Flat frame captured`) + } else if (event.state === 'FAILED') { this.running = false + this.savedPath = undefined + this.electron.autoResizeWindow() this.prime.message(`Failed to find an optimal exposure time from given parameters`, 'error') - }) - } - - if (!this.running) { - ngZone.run(() => { - this.cameraExposure.reset() - }) - } + } + }) }) electron.on('CAMERA.UPDATED', event => { @@ -158,13 +154,17 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { ngOnDestroy() { } async showCameraDialog() { - if (this.camera.name && await CameraComponent.showAsDialog(this.browserWindow, 'FLAT_WIZARD', this.camera, this.request.captureRequest)) { - this.preference.cameraStartCaptureForFlatWizard(this.camera).set(this.request.captureRequest) + if (this.camera.id && await CameraComponent.showAsDialog(this.browserWindow, 'FLAT_WIZARD', this.camera, this.request.capture)) { + this.preference.cameraStartCaptureForFlatWizard(this.camera).set(this.request.capture) } } cameraChanged() { - this.updateEntryFromCamera(this.camera) + if (this.camera.id) { + const cameraPreference = this.preference.cameraPreference(this.camera).get() + this.request.capture = this.preference.cameraStartCaptureForFlatWizard(this.camera).get(cameraPreference) + this.updateEntryFromCamera(this.camera) + } } wheelConnect() { @@ -176,43 +176,51 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { } private updateEntryFromCamera(camera?: Camera) { - if (camera) { - const request = this.preference.cameraStartCaptureForFlatWizard(camera).get(this.request.captureRequest) - - if (camera.connected) { - updateCameraStartCaptureFromCamera(request, camera) - } - - this.request.captureRequest = request + if (camera && camera.connected) { + updateCameraStartCaptureFromCamera(this.request.capture, camera) } } wheelChanged() { if (this.wheel) { let filters: FilterSlot[] = [] + let filtersChanged = true if (this.wheel.count <= 0) { this.filters = [] - this.selectedFilters = [] return } else if (this.wheel.count !== this.filters.length) { filters = new Array(this.wheel.count) } else { filters = this.filters + filtersChanged = false } - const preference = this.preference.wheelPreference(this.wheel).get() + if (filtersChanged) { + const preference = this.preference.wheelPreference(this.wheel).get() + + for (let position = 1; position <= filters.length; position++) { + const name = preference.names?.[position - 1] ?? `Filter #${position}` + const offset = preference.offsets?.[position - 1] ?? 0 + const dark = position === preference.shutterPosition + const filter = { position, name, dark, offset } + filters[position - 1] = filter + } - for (let position = 1; position <= filters.length; position++) { - const name = preference.names?.[position - 1] ?? `Filter #${position}` - const filter = { position, name, dark: false, offset: 0 } - filters[position - 1] = filter + this.filters = filters + this.selectedFilters = this.selectedFiltersMap.get(this.wheel.name) ?? [] + this.selectedFiltersMap.set(this.wheel.name, this.selectedFilters) } + } + } - this.filters = filters + async chooseSavePath() { + const defaultPath = this.request.capture.savePath + const path = await this.electron.openDirectory({ defaultPath }) - this.selectedFilters = this.selectedFiltersMap.get(this.wheel.name) ?? [] - this.selectedFiltersMap.set(this.wheel.name, this.selectedFilters) + if (path) { + this.request.capture.savePath = path + this.savePreference() } } @@ -226,4 +234,10 @@ export class FlatWizardComponent implements AfterViewInit, OnDestroy { stop() { this.api.flatWizardStop(this.camera) } + + savePreference() { + if (this.camera.id) { + this.preference.cameraStartCaptureForFlatWizard(this.camera).set(this.request.capture) + } + } } diff --git a/desktop/src/app/focuser/focuser.component.html b/desktop/src/app/focuser/focuser.component.html index 52d394f2f..52fd7de6b 100644 --- a/desktop/src/app/focuser/focuser.component.html +++ b/desktop/src/app/focuser/focuser.component.html @@ -17,41 +17,41 @@
- {{ temperature }}°C + {{ focuser.temperature | number: '1.2-2' }}°C
- - + +
- - +
- - -
- -
diff --git a/desktop/src/app/focuser/focuser.component.ts b/desktop/src/app/focuser/focuser.component.ts index 9e7f62522..d25235a30 100644 --- a/desktop/src/app/focuser/focuser.component.ts +++ b/desktop/src/app/focuser/focuser.component.ts @@ -3,8 +3,8 @@ import { ActivatedRoute } from '@angular/router' import hotkeys from 'hotkeys-js' import { ApiService } from '../../shared/services/api.service' import { ElectronService } from '../../shared/services/electron.service' -import { LocalStorageService } from '../../shared/services/local-storage.service' -import { EMPTY_FOCUSER, Focuser, FocuserPreference, focuserPreferenceKey } from '../../shared/types/focuser.types' +import { PreferenceService } from '../../shared/services/preference.service' +import { EMPTY_FOCUSER, Focuser } from '../../shared/types/focuser.types' import { AppComponent } from '../app.component' @Component({ @@ -17,26 +17,14 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { readonly focuser = structuredClone(EMPTY_FOCUSER) moving = false - position = 0 - hasThermometer = false - temperature = 0 - canAbsoluteMove = false - canRelativeMove = false - canAbort = false - canReverse = false - reversed = false - canSync = false - hasBacklash = false - maxPosition = 0 - stepsRelative = 0 stepsAbsolute = 0 constructor( private app: AppComponent, private api: ApiService, - private electron: ElectronService, - private storage: LocalStorageService, + electron: ElectronService, + private preference: PreferenceService, private route: ActivatedRoute, ngZone: NgZone, ) { @@ -69,10 +57,10 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { hotkeys('shift+right', (event) => { event.preventDefault(); this.moveOut(0.5) }) hotkeys('space', (event) => { event.preventDefault(); this.abort() }) hotkeys('ctrl+enter', (event) => { event.preventDefault(); this.moveTo() }) - hotkeys('up', (event) => { event.preventDefault(); this.stepsRelative = Math.min(this.maxPosition, this.stepsRelative + 1) }) + hotkeys('up', (event) => { event.preventDefault(); this.stepsRelative = Math.min(this.focuser.maxPosition, this.stepsRelative + 1) }) hotkeys('down', (event) => { event.preventDefault(); this.stepsRelative = Math.max(0, this.stepsRelative - 1) }) hotkeys('-', (event) => { event.preventDefault(); this.stepsAbsolute = Math.max(0, this.stepsAbsolute - 1) }) - hotkeys('=', (event) => { event.preventDefault(); this.stepsAbsolute = Math.min(this.maxPosition, this.stepsAbsolute + 1) }) + hotkeys('=', (event) => { event.preventDefault(); this.stepsAbsolute = Math.min(this.focuser.maxPosition, this.stepsAbsolute + 1) }) } async ngAfterViewInit() { @@ -88,8 +76,8 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { } async focuserChanged(focuser?: Focuser) { - if (focuser && focuser.name) { - focuser = await this.api.focuser(focuser.name) + if (focuser && focuser.id) { + focuser = await this.api.focuser(focuser.id) Object.assign(this.focuser, focuser) this.loadPreference() @@ -109,33 +97,33 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { } } - moveIn(stepSize: number = 1) { + async moveIn(stepSize: number = 1) { if (!this.moving) { this.moving = true - this.api.focuserMoveIn(this.focuser, Math.trunc(this.stepsRelative * stepSize)) + await this.api.focuserMoveIn(this.focuser, Math.trunc(this.stepsRelative * stepSize)) this.savePreference() } } - moveOut(stepSize: number = 1) { + async moveOut(stepSize: number = 1) { if (!this.moving) { this.moving = true - this.api.focuserMoveOut(this.focuser, Math.trunc(this.stepsRelative * stepSize)) + await this.api.focuserMoveOut(this.focuser, Math.trunc(this.stepsRelative * stepSize)) this.savePreference() } } - moveTo() { - if (!this.moving && this.stepsAbsolute !== this.position) { + async moveTo() { + if (!this.moving && this.stepsAbsolute !== this.focuser.position) { this.moving = true - this.api.focuserMoveTo(this.focuser, this.stepsAbsolute) + await this.api.focuserMoveTo(this.focuser, this.stepsAbsolute) this.savePreference() } } - sync() { + async sync() { if (!this.moving) { - this.api.focuserSync(this.focuser, this.stepsAbsolute) + await this.api.focuserSync(this.focuser, this.stepsAbsolute) this.savePreference() } } @@ -145,27 +133,14 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { } private update() { - if (!this.focuser.name) { - return + if (this.focuser.id) { + this.moving = this.focuser.moving } - - this.moving = this.focuser.moving - this.position = this.focuser.position - this.hasThermometer = this.focuser.hasThermometer - this.temperature = this.focuser.temperature - this.canAbsoluteMove = this.focuser.canAbsoluteMove - this.canRelativeMove = this.focuser.canRelativeMove - this.canAbort = this.focuser.canAbort - this.canReverse = this.focuser.canReverse - this.reversed = this.focuser.reversed - this.canSync = this.focuser.canSync - this.hasBacklash = this.focuser.hasBacklash - this.maxPosition = this.focuser.maxPosition } private loadPreference() { - if (this.focuser.name) { - const preference = this.storage.get(focuserPreferenceKey(this.focuser), {}) + if (this.focuser.id) { + const preference = this.preference.focuserPreference(this.focuser).get() this.stepsRelative = preference.stepsRelative ?? 100 this.stepsAbsolute = preference.stepsAbsolute ?? this.focuser.position } @@ -173,12 +148,10 @@ export class FocuserComponent implements AfterViewInit, OnDestroy { private savePreference() { if (this.focuser.connected) { - const preference: FocuserPreference = { - stepsRelative: this.stepsRelative, - stepsAbsolute: this.stepsAbsolute, - } - - this.storage.set(focuserPreferenceKey(this.focuser), preference) + const preference = this.preference.focuserPreference(this.focuser).get() + preference.stepsAbsolute = this.stepsAbsolute + preference.stepsRelative = this.stepsRelative + this.preference.focuserPreference(this.focuser).set(preference) } } } \ No newline at end of file diff --git a/desktop/src/app/framing/framing.component.ts b/desktop/src/app/framing/framing.component.ts index 390825b78..8e6936846 100644 --- a/desktop/src/app/framing/framing.component.ts +++ b/desktop/src/app/framing/framing.component.ts @@ -96,11 +96,12 @@ export class FramingComponent implements AfterViewInit, OnDestroy { } private frameFromData(data: FramingData) { + console.info(data) this.rightAscension = data.rightAscension ?? this.rightAscension this.declination = data.declination ?? this.declination - this.width = data.width ?? this.width - this.height = data.height ?? this.height - this.fov = data.fov ?? this.fov + this.width = data.width || this.width + this.height = data.height || this.height + this.fov = data.fov || this.fov if (data.rotation === 0 || data.rotation) this.rotation = data.rotation if (data.rightAscension && data.declination) { diff --git a/desktop/src/app/guider/guider.component.ts b/desktop/src/app/guider/guider.component.ts index 70ff30f5e..a3c79fd0c 100644 --- a/desktop/src/app/guider/guider.component.ts +++ b/desktop/src/app/guider/guider.component.ts @@ -366,8 +366,8 @@ export class GuiderComponent implements AfterViewInit, OnDestroy { } async guideOutputChanged() { - if (this.guideOutput) { - const guideOutput = await this.api.guideOutput(this.guideOutput.name) + if (this.guideOutput?.id) { + const guideOutput = await this.api.guideOutput(this.guideOutput.id) Object.assign(this.guideOutput, guideOutput) this.update() diff --git a/desktop/src/app/home/home.component.html b/desktop/src/app/home/home.component.html index 019937f3a..9bd830683 100644 --- a/desktop/src/app/home/home.component.html +++ b/desktop/src/app/home/home.component.html @@ -2,7 +2,7 @@
+ tooltipPosition="bottom" [positionLeft]="16" /> @@ -38,62 +38,63 @@ [text]="true" pTooltip="Disconnect" tooltipPosition="bottom" />
-
-
+
+
+ styleClass="min-w-full w-full px-1 py-2 flex-column">
Camera
+ styleClass="min-w-full w-full px-1 py-2 flex-column">
Mount
+ styleClass="min-w-full w-full px-1 py-2 flex-column">
Guider
+ styleClass="min-w-full w-full px-1 py-2 flex-column">
Filter Wheel
+ styleClass="min-w-full w-full px-1 py-2 flex-column">
Focuser
- +
Rotator
- +
Dome
- +
Switch
- +
Sky Atlas
+ styleClass="min-w-full w-full px-1 py-2 flex-column">
Alignment
+ styleClass="min-w-full w-full px-1 py-2 flex-column">
Sequencer
- +
Image Viewer
- +
Framing
+ styleClass="min-w-full w-full px-1 py-2 flex-column">
Flat Wizard
+ styleClass="min-w-full w-full px-1 py-2 flex-column">
INDI
-
- +
+
Calculator
- +
Settings
- +
About
+
+ + +
@@ -193,4 +198,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/desktop/src/app/home/home.component.scss b/desktop/src/app/home/home.component.scss index ef0bd1a18..4325edd42 100644 --- a/desktop/src/app/home/home.component.scss +++ b/desktop/src/app/home/home.component.scss @@ -6,10 +6,10 @@ } } - p-button ::ng-deep { + .buttons p-button ::ng-deep { display: contents; - &.open { + >button { min-height: 56px; max-height: 56px; display: flex; @@ -25,8 +25,27 @@ } } } -} -.grid::-webkit-scrollbar { - display: none; + .indicators { + top: 50%; + right: 0.6rem; + + .indicator { + background-color: #3f3f46; + width: 0.7rem; + height: 0.7rem; + transition: background-color 0.2s, color 0.2s, border-color 0.2s, box-shadow 0.2s, outline-color 0.2s; + border-radius: 50%; + cursor: pointer; + margin: 1px; + + &.selected { + background-color: #60a5fa; + } + } + } + + .grid::-webkit-scrollbar { + display: none; + } } \ No newline at end of file diff --git a/desktop/src/app/home/home.component.ts b/desktop/src/app/home/home.component.ts index 33239f4ad..37a9d7173 100644 --- a/desktop/src/app/home/home.component.ts +++ b/desktop/src/app/home/home.component.ts @@ -1,5 +1,5 @@ import { AfterContentInit, Component, HostListener, NgZone, OnDestroy, ViewChild } from '@angular/core' -import path from 'path' +import { dirname } from 'path' import { MenuItem } from 'primeng/api' import { DeviceListMenuComponent } from '../../shared/components/device-list-menu/device-list-menu.component' import { DialogMenuComponent } from '../../shared/components/dialog-menu/dialog-menu.component' @@ -13,6 +13,7 @@ import { Device } from '../../shared/types/device.types' import { Focuser } from '../../shared/types/focuser.types' import { CONNECTION_TYPES, ConnectionDetails, EMPTY_CONNECTION_DETAILS, HomeWindowType } from '../../shared/types/home.types' import { Mount } from '../../shared/types/mount.types' +import { Rotator } from '../../shared/types/rotator.types' import { FilterWheel } from '../../shared/types/wheel.types' import { deviceComparator } from '../../shared/utils/comparators' import { AppComponent } from '../app.component' @@ -22,6 +23,7 @@ type MappedDevice = { 'MOUNT': Mount 'FOCUSER': Focuser 'WHEEL': FilterWheel + 'ROTATOR': Rotator } @Component({ @@ -39,7 +41,7 @@ export class HomeComponent implements AfterContentInit, OnDestroy { readonly connectionTypes = Array.from(CONNECTION_TYPES) showConnectionDialog = false - readonly connections: ConnectionDetails[] = [] + connections: ConnectionDetails[] = [] connection?: ConnectionDetails newConnection?: [ConnectionDetails, ConnectionDetails | undefined] skyAtlasProgress?: number = undefined @@ -48,10 +50,12 @@ export class HomeComponent implements AfterContentInit, OnDestroy { mounts: Mount[] = [] focusers: Focuser[] = [] wheels: FilterWheel[] = [] + rotators: Rotator[] = [] domes: Camera[] = [] - rotators: Camera[] = [] switches: Camera[] = [] + currentPage = 0 + get connected() { return !!this.connection && this.connection.connected } @@ -100,11 +104,19 @@ export class HomeComponent implements AfterContentInit, OnDestroy { return this.hasCamera } - get hasINDI() { + get hasDevices() { return this.hasCamera || this.hasMount || this.hasFocuser || this.hasWheel || this.hasDome || this.hasRotator || this.hasSwitch } + get hasINDI() { + return this.connection?.type === 'INDI' && this.hasDevices + } + + get hasAlpaca() { + return this.connection?.type === 'ALPACA' && this.hasDevices + } + readonly deviceModel: MenuItem[] = [] readonly imageModel: MenuItem[] = [ @@ -186,6 +198,16 @@ export class HomeComponent implements AfterContentInit, OnDestroy { }, ) + this.startListening('ROTATOR', + (device) => { + return this.rotators.push(device) + }, + (device) => { + this.rotators.splice(this.rotators.findIndex(e => e.id === device.id), 1) + return this.rotators.length + }, + ) + electron.on('CONNECTION.CLOSED', event => { if (this.connection?.id === event.id) { ngZone.run(() => { @@ -204,7 +226,7 @@ export class HomeComponent implements AfterContentInit, OnDestroy { }) }) - this.connections = preference.connections.get() + this.connections = preference.connections.get().sort((a, b) => (b.connectedAt ?? 0) - (a.connectedAt ?? 0)) this.connections.forEach(e => { e.id = undefined; e.connected = false }) this.connection = this.connections[0] } @@ -217,13 +239,12 @@ export class HomeComponent implements AfterContentInit, OnDestroy { this.mounts = await this.api.mounts() this.focusers = await this.api.focusers() this.wheels = await this.api.wheels() + this.rotators = await this.api.rotators() } } @HostListener('window:unload') - ngOnDestroy() { - this.disconnect() - } + ngOnDestroy() { } addConnection() { this.newConnection = [structuredClone(EMPTY_CONNECTION_DETAILS), undefined] @@ -243,7 +264,7 @@ export class HomeComponent implements AfterContentInit, OnDestroy { this.connections.splice(index, 1) if (connection === this.connection) { - this.connection = undefined + this.connection = this.connections[0] } this.preference.connections.set(this.connections) @@ -260,7 +281,9 @@ export class HomeComponent implements AfterContentInit, OnDestroy { } // New. else { - this.connections.push(this.newConnection[0]) + const newConnection = structuredClone(this.newConnection[0]) + this.connections = [...this.connections, newConnection] + this.connection = newConnection } } @@ -296,14 +319,15 @@ export class HomeComponent implements AfterContentInit, OnDestroy { } } - private openDevice(type: K) { + private openDevice(type: K, header: string) { this.deviceModel.length = 0 const devices: Device[] = type === 'CAMERA' ? this.cameras : type === 'MOUNT' ? this.mounts : type === 'FOCUSER' ? this.focusers : type === 'WHEEL' ? this.wheels - : [] + : type === 'ROTATOR' ? this.rotators + : [] if (devices.length === 0) return if (devices.length === 1) return this.openDeviceWindow(type, devices[0] as any) @@ -318,6 +342,7 @@ export class HomeComponent implements AfterContentInit, OnDestroy { }) } + this.deviceMenu.header = header this.deviceMenu.show() } @@ -335,22 +360,26 @@ export class HomeComponent implements AfterContentInit, OnDestroy { case 'WHEEL': this.browserWindow.openWheel({ bringToFront: true, data: device as FilterWheel }) break + case 'ROTATOR': + this.browserWindow.openRotator({ bringToFront: true, data: device as Rotator }) + break } } private async openImage(force: boolean = false) { if (force || this.cameras.length === 0) { - const defaultPath = this.preference.homeImageDirectory.get() - const filePath = await this.electron.openFits({ defaultPath }) + const preference = this.preference.homePreference.get() + const path = await this.electron.openImage({ defaultPath: preference.imagePath }) - if (filePath) { - this.preference.homeImageDirectory.set(path.dirname(filePath)) - this.browserWindow.openImage({ path: filePath, source: 'PATH' }) + if (path) { + preference.imagePath = dirname(path) + this.preference.homePreference.set(preference) + this.browserWindow.openImage({ path, source: 'PATH' }) } } else { const camera = await this.imageMenu.show(this.cameras) - if (camera) { + if (camera && camera !== 'NONE') { this.browserWindow.openCameraImage(camera) } } @@ -362,7 +391,8 @@ export class HomeComponent implements AfterContentInit, OnDestroy { case 'CAMERA': case 'FOCUSER': case 'WHEEL': - this.openDevice(type) + case 'ROTATOR': + this.openDevice(type, type) break case 'GUIDER': this.browserWindow.openGuider({ bringToFront: true }) @@ -414,14 +444,6 @@ export class HomeComponent implements AfterContentInit, OnDestroy { } } catch { this.connection.connected = false - - this.cameras = [] - this.mounts = [] - this.focusers = [] - this.wheels = [] - this.domes = [] - this.rotators = [] - this.switches = [] } } else { const statuses = await this.api.connectionStatuses() @@ -431,6 +453,8 @@ export class HomeComponent implements AfterContentInit, OnDestroy { if (!connection.connected && (status.host === connection.host || status.ip === connection.host) && status.port === connection.port) { + connection.id = status.id + connection.type = status.type connection.connected = true this.connection = connection break @@ -442,5 +466,62 @@ export class HomeComponent implements AfterContentInit, OnDestroy { } } } + + if (!this.connection?.connected) { + this.cameras = [] + this.mounts = [] + this.focusers = [] + this.wheels = [] + this.domes = [] + this.rotators = [] + this.switches = [] + } + } + + private scrollPageOf(element: Element) { + return parseInt(element.getAttribute('scroll-page') || '0') + } + + scrolled(event: Event) { + function isVisible(element: Element) { + const bound = element.getBoundingClientRect() + + return bound.top >= 0 && + bound.left >= 0 && + bound.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + bound.right <= (window.innerWidth || document.documentElement.clientWidth) + } + + let page = 0 + const scrollChidren = document.getElementsByClassName('scroll-child') + + for (let i = 0; i < scrollChidren.length; i++) { + const child = scrollChidren[i] + + if (isVisible(child)) { + page = Math.max(page, this.scrollPageOf(child)) + } + } + + this.currentPage = page + } + + scrollTo(event: Event, page: number) { + this.currentPage = page + this.scrollToPage(page) + event.stopImmediatePropagation() + } + + scrollToPage(page: number) { + const scrollChidren = document.getElementsByClassName('scroll-child') + + for (let i = 0; i < scrollChidren.length; i++) { + const child = scrollChidren[i] + + if (this.scrollPageOf(child) === page) { + child.scrollIntoView({ behavior: 'smooth' }) + break + } + } } } diff --git a/desktop/src/app/image/image.component.html b/desktop/src/app/image/image.component.html index e2fb0d440..ed6372920 100644 --- a/desktop/src/app/image/image.component.html +++ b/desktop/src/app/image/image.component.html @@ -10,7 +10,7 @@ - @@ -20,9 +20,9 @@ - - + {{ s.hfd.toFixed(1) }} @@ -31,7 +31,7 @@ - @for (item of fovs; track $index) { + @for (item of fov.fovs; track $index) { @if (item.enabled && item.computed) { @@ -40,7 +40,7 @@
- X: {{ roiX }} Y: {{ roiY }} W: {{ roiWidth }} H: {{ roiHeight }} + X: {{ imageROI.x }} Y: {{ imageROI.y }} W: {{ imageROI.width }} H: {{ imageROI.height }}
@@ -50,18 +50,20 @@ -
-
- - +
- - + + +
+
+ - @@ -107,7 +109,7 @@
- @@ -146,27 +148,27 @@ -
- + - +
- +
- +
@@ -174,8 +176,8 @@
- +
@@ -184,60 +186,60 @@
- +
- +
- +
- +
+ [value]="(solver.solved.width.toFixed(2)) + ' x ' + (solver.solved.height.toFixed(2))" />
- +
- - - - + +
- + -
@@ -245,17 +247,18 @@
+ [(ngModel)]="stretchShadow" locale="en" /> + [(ngModel)]="stretchHighlight" locale="en" />
- +
@@ -276,16 +279,17 @@ -
- +
- +
{{ item | enum }} @@ -302,9 +306,9 @@
- + [(ngModel)]="scnr.amount" locale="en" [allowEmpty]="false" />
@@ -314,10 +318,10 @@
-
- + Name @@ -406,7 +410,7 @@
-
@@ -483,15 +487,15 @@
-->
- - -
-
- @for (item of fovs; track $index) { +
+ @for (item of fov.fovs; track $index) {
@@ -522,11 +526,11 @@
-
- @@ -540,15 +544,15 @@
- +
-
- @@ -561,7 +565,38 @@
- + + +
+ + +
+
+ + + + +
+
+ +
+
+ + + + +
+
+ Transformed + +
+
+ +
diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 3b3c4d9e4..4440b1f58 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -1,14 +1,14 @@ -import { AfterViewInit, Component, ElementRef, HostListener, NgZone, OnDestroy, ViewChild } from '@angular/core' +import { AfterViewInit, Component, ElementRef, HostListener, NgZone, OnDestroy, ViewChild, computed, model } from '@angular/core' import { ActivatedRoute } from '@angular/router' import { Interactable } from '@interactjs/types/index' import hotkeys from 'hotkeys-js' import interact from 'interactjs' import createPanZoom, { PanZoom } from 'panzoom' -import * as path from 'path' -import { MenuItem } from 'primeng/api' +import { basename, dirname, extname } from 'path' import { ContextMenu } from 'primeng/contextmenu' import { DeviceListMenuComponent } from '../../shared/components/device-list-menu/device-list-menu.component' import { HistogramComponent } from '../../shared/components/histogram/histogram.component' +import { ExtendedMenuItem } from '../../shared/components/menu-item/menu-item.component' import { SEPARATOR_MENU_ITEM } from '../../shared/constants' import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' @@ -17,7 +17,7 @@ import { PreferenceService } from '../../shared/services/preference.service' import { PrimeService } from '../../shared/services/prime.service' import { CheckableMenuItem, ToggleableMenuItem } from '../../shared/types/app.types' import { Angle, AstronomicalObject, DeepSkyObject, EquatorialCoordinateJ2000, Star } from '../../shared/types/atlas.types' -import { DEFAULT_FOV, DetectedStar, EMPTY_IMAGE_SOLVED, FITSHeaderItem, FOV, FOVCamera, FOVTelescope, ImageAnnotation, ImageChannel, ImageData, ImageInfo, ImagePreference, ImageSolved, ImageStatisticsBitOption, SCNRProtectionMethod, SCNR_PROTECTION_METHODS } from '../../shared/types/image.types' +import { DEFAULT_FOV, EMPTY_IMAGE_SOLVED, FOV, IMAGE_STATISTICS_BIT_OPTIONS, ImageAnnotation, ImageAnnotationDialog, ImageChannel, ImageData, ImageDetectStars, ImageFITSHeadersDialog, ImageFOVDialog, ImageInfo, ImageROI, ImageSCNRDialog, ImageSaveDialog, ImageSolved, ImageSolverDialog, ImageStatisticsBitOption, ImageStretchDialog, ImageTransformation, SCNR_PROTECTION_METHODS } from '../../shared/types/image.types' import { Mount } from '../../shared/types/mount.types' import { DEFAULT_SOLVER_TYPES } from '../../shared/types/settings.types' import { CoordinateInterpolator, InterpolatedCoordinate } from '../../shared/utils/coordinate-interpolation' @@ -45,40 +45,67 @@ export class ImageComponent implements AfterViewInit, OnDestroy { @ViewChild('histogram') private readonly histogram!: HistogramComponent - debayer = true - calibrate = true - mirrorHorizontal = false - mirrorVertical = false - invert = false - - readonly scnrChannelOptions: ImageChannel[] = ['NONE', 'RED', 'GREEN', 'BLUE'] - readonly scnrProtectionMethodOptions: SCNRProtectionMethod[] = [...SCNR_PROTECTION_METHODS] - - showSCNRDialog = false - scnrChannel: ImageChannel = 'NONE' - scnrAmount = 0.5 - scnrProtectionMethod: SCNRProtectionMethod = 'AVERAGE_NEUTRAL' - - showAnnotationDialog = false - annotateWithStarsAndDSOs = true - annotateWithMinorPlanets = false - annotateWithMinorPlanetsMagLimit = 12.0 - - autoStretched = true - showStretchingDialog = false - stretchShadowHighlight = [0, 65536] - stretchMidtone = 32768 - - showSolverDialog = false - solving = false - solved = false - solverBlind = true - solverCenterRA: Angle = '' - solverCenterDEC: Angle = '' - solverRadius = 4 - readonly imageSolved = structuredClone(EMPTY_IMAGE_SOLVED) - readonly solverTypes = Array.from(DEFAULT_SOLVER_TYPES) - solverType = this.solverTypes[0] + imageInfo?: ImageInfo + private imageURL!: string + imageData: ImageData = {} + + readonly scnrChannels: { name: string, value?: ImageChannel }[] = [ + { name: 'None', value: undefined }, + { name: 'Red', value: 'RED' }, + { name: 'Green', value: 'GREEN' }, + { name: 'Blue', value: 'BLUE' }, + ] + readonly scnrMethods = Array.from(SCNR_PROTECTION_METHODS) + readonly scnr: ImageSCNRDialog = { + showDialog: false, + amount: 0.5, + method: 'AVERAGE_NEUTRAL', + } + + readonly stretch: ImageStretchDialog = { + showDialog: false, + auto: true, + shadow: 0, + highlight: 1, + midtone: 0.5 + } + + readonly stretchShadow = model(0) + readonly stretchHighlight = model(65536) + readonly stretchMidtone = model(32768) + readonly stretchShadowAndHighlight = computed(() => [this.stretchShadow(), this.stretchHighlight()]) + + readonly transformation: ImageTransformation = { + force: false, + debayer: true, + stretch: this.stretch, + mirrorHorizontal: false, + mirrorVertical: false, + invert: false, + scnr: this.scnr + } + + calibrationViaCamera = true + + readonly annotation: ImageAnnotationDialog = { + showDialog: false, + useStarsAndDSOs: true, + useMinorPlanets: false, + minorPlanetsMagLimit: 18.0, + useSimbad: false + } + + readonly solver: ImageSolverDialog = { + showDialog: false, + solving: false, + blind: true, + centerRA: '', + centerDEC: '', + radius: 4, + solved: structuredClone(EMPTY_IMAGE_SOLVED), + types: Array.from(DEFAULT_SOLVER_TYPES), + type: 'ASTAP' + } crossHair = false annotations: ImageAnnotation[] = [] @@ -87,37 +114,30 @@ export class ImageComponent implements AfterViewInit, OnDestroy { annotationInfo?: AstronomicalObject & Partial annotationIsVisible = false - detectedStars: DetectedStar[] = [] - detectedStarsIsVisible = false + readonly detectedStars: ImageDetectStars = { + visible: false, + stars: [] + } - showFITSHeadersDialog = false - fitsHeaders: FITSHeaderItem[] = [] + readonly fitsHeaders: ImageFITSHeadersDialog = { + showDialog: false, + headers: [] + } showStatisticsDialog = false - readonly statisticsBitOptions: ImageStatisticsBitOption[] = [ - { name: 'Normalized: [0, 1]', rangeMax: 1, bitLength: 16 }, - { name: '8-bit: [0, 255]', rangeMax: 255, bitLength: 8 }, - { name: '9-bit: [0, 511]', rangeMax: 511, bitLength: 9 }, - { name: '10-bit: [0, 1023]', rangeMax: 1023, bitLength: 10 }, - { name: '12-bit: [0, 4095]', rangeMax: 4095, bitLength: 12 }, - { name: '14-bit: [0, 16383]', rangeMax: 16383, bitLength: 14 }, - { name: '16-bit: [0, 65535]', rangeMax: 65535, bitLength: 16 }, - ] - + readonly statisticsBitOptions: ImageStatisticsBitOption[] = IMAGE_STATISTICS_BIT_OPTIONS statisticsBitLength = this.statisticsBitOptions[0] - imageInfo?: ImageInfo - showFOVDialog = false - readonly fov = structuredClone(DEFAULT_FOV) - fovs: FOV[] = [] - editedFOV?: FOV - showFOVCamerasDialog = false - fovCameras: FOVCamera[] = [] - fovCamera?: FOVCamera - showFOVTelescopesDialog = false - fovTelescopes: FOVTelescope[] = [] - fovTelescope?: FOVTelescope + readonly fov: ImageFOVDialog = { + ...structuredClone(DEFAULT_FOV), + showDialog: false, + fovs: [], + showCameraDialog: false, + cameras: [], + showTelescopeDialog: false, + telescopes: [], + } get canAddFOV() { return this.fov.aperture && this.fov.focalLength && @@ -127,39 +147,61 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } private panZoom?: PanZoom - private imageURL!: string private imageMouseX = 0 private imageMouseY = 0 - private imageData: ImageData = {} - roiX = 0 - roiY = 0 - roiWidth = 128 - roiHeight = 128 roiInteractable?: Interactable + readonly imageROI: ImageROI = { + x: 0, + y: 0, + width: 0, + height: 0 + } + + readonly saveAs: ImageSaveDialog = { + showDialog: false, + format: 'FITS', + bitpix: 'BYTE', + path: '', + shouldBeTransformed: true, + transformation: this.transformation + } - private readonly saveAsMenuItem: MenuItem = { + private readonly saveAsMenuItem: ExtendedMenuItem = { label: 'Save as...', icon: 'mdi mdi-content-save', command: async () => { - const path = await this.electron.saveFits() - if (path) this.api.saveImageAs(this.imageData.path!, path) + const preference = this.preference.imagePreference.get() + + const path = await this.electron.saveImage({ defaultPath: preference.savePath }) + + if (path) { + const extension = extname(path).toLowerCase() + this.saveAs.format = extension === '.xisf' ? 'XISF' : + extension === '.png' ? 'PNG' : extension === '.jpg' ? 'JPG' : 'FITS' + this.saveAs.bitpix = this.imageInfo?.bitpix || 'BYTE' + this.saveAs.path = path + this.saveAs.showDialog = true + + preference.savePath = dirname(path) + this.preference.imagePreference.set(preference) + } }, } - private readonly plateSolveMenuItem: MenuItem = { + private readonly plateSolveMenuItem: ExtendedMenuItem = { label: 'Plate Solve', icon: 'mdi mdi-sigma', command: () => { - this.showSolverDialog = true + this.solver.showDialog = true }, } - private readonly stretchMenuItem: MenuItem = { + private readonly stretchMenuItem: ExtendedMenuItem = { label: 'Stretch', icon: 'mdi mdi-chart-histogram', command: () => { - this.showStretchingDialog = true + this.stretch.showDialog = true }, } @@ -173,12 +215,12 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly scnrMenuItem: MenuItem = { + private readonly scnrMenuItem: ExtendedMenuItem = { label: 'SCNR', icon: 'mdi mdi-palette', disabled: true, command: () => { - this.showSCNRDialog = true + this.scnr.showDialog = true }, } @@ -187,8 +229,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { icon: 'mdi mdi-flip-horizontal', checked: false, command: () => { - this.mirrorHorizontal = !this.mirrorHorizontal - this.horizontalMirrorMenuItem.checked = this.mirrorHorizontal + this.transformation.mirrorHorizontal = !this.transformation.mirrorHorizontal + this.horizontalMirrorMenuItem.checked = this.transformation.mirrorHorizontal this.loadImage() }, } @@ -198,8 +240,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { icon: 'mdi mdi-flip-vertical', checked: false, command: () => { - this.mirrorVertical = !this.mirrorVertical - this.verticalMirrorMenuItem.checked = this.mirrorVertical + this.transformation.mirrorVertical = !this.transformation.mirrorVertical + this.verticalMirrorMenuItem.checked = this.transformation.mirrorVertical this.loadImage() }, } @@ -213,18 +255,13 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly calibrateMenuItem: CheckableMenuItem = { - label: 'Calibrate', - icon: 'mdi mdi-tools', - checked: true, - command: () => { - this.calibrate = !this.calibrate - this.calibrateMenuItem.checked = this.calibrate - this.loadImage() - }, + private readonly calibrationMenuItem: ExtendedMenuItem = { + label: 'Calibration', + icon: 'mdi mdi-wrench', + items: [], } - private readonly statisticsMenuItem: MenuItem = { + private readonly statisticsMenuItem: ExtendedMenuItem = { icon: 'mdi mdi-chart-histogram', label: 'Statistics', command: () => { @@ -233,15 +270,15 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly fitsHeaderMenuItem: MenuItem = { + private readonly fitsHeaderMenuItem: ExtendedMenuItem = { icon: 'mdi mdi-list-box', label: 'FITS Header', command: () => { - this.showFITSHeadersDialog = true + this.fitsHeaders.showDialog = true }, } - private readonly pointMountHereMenuItem: MenuItem = { + private readonly pointMountHereMenuItem: ExtendedMenuItem = { label: 'Point mount here', icon: 'mdi mdi-telescope', disabled: true, @@ -252,15 +289,15 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly frameAtThisCoordinateMenuItem: MenuItem = { + private readonly frameAtThisCoordinateMenuItem: ExtendedMenuItem = { label: 'Frame at this coordinate', icon: 'mdi mdi-image', disabled: true, command: () => { - const coordinate = this.mouseCoordinateInterpolation?.interpolateAsText(this.imageMouseX, this.imageMouseY, false, false, false) + const coordinate = this.mouseCoordinateInterpolation?.interpolate(this.imageMouseX, this.imageMouseY, false, false) if (coordinate) { - this.browserWindow.openFraming({ data: { rightAscension: coordinate.alpha, declination: coordinate.delta } }) + this.frame(coordinate) } }, } @@ -281,7 +318,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { toggleable: true, toggled: false, command: () => { - this.showAnnotationDialog = true + this.annotation.showDialog = true }, toggle: (event) => { event.originalEvent?.stopImmediatePropagation() @@ -296,14 +333,14 @@ export class ImageComponent implements AfterViewInit, OnDestroy { toggleable: false, toggled: false, command: async () => { - this.detectedStars = await this.api.detectStars(this.imageData.path!) - this.detectedStarsIsVisible = this.detectedStars.length > 0 - this.detectStarsMenuItem.toggleable = this.detectedStarsIsVisible - this.detectStarsMenuItem.toggled = this.detectedStarsIsVisible + this.detectedStars.stars = await this.api.detectStars(this.imageData.path!) + this.detectedStars.visible = this.detectedStars.stars.length > 0 + this.detectStarsMenuItem.toggleable = this.detectedStars.visible + this.detectStarsMenuItem.toggled = this.detectedStars.visible }, toggle: (event) => { event.originalEvent?.stopImmediatePropagation() - this.detectedStarsIsVisible = event.checked + this.detectedStars.visible = event.checked }, } @@ -347,19 +384,19 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }, } - private readonly fovMenuItem: MenuItem = { + private readonly fovMenuItem: ExtendedMenuItem = { label: 'Field of View', icon: 'mdi mdi-camera-metering-spot', command: () => { - this.showFOVDialog = !this.showFOVDialog + this.fov.showDialog = !this.fov.showDialog - if (this.showFOVDialog) { - this.fovs.forEach(e => this.computeFOV(e)) + if (this.fov.showDialog) { + this.fov.fovs.forEach(e => this.computeFOV(e)) } }, } - private readonly overlayMenuItem: MenuItem = { + private readonly overlayMenuItem: ExtendedMenuItem = { label: 'Overlay', icon: 'mdi mdi-layers', items: [ @@ -382,7 +419,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.horizontalMirrorMenuItem, this.verticalMirrorMenuItem, this.invertMenuItem, - this.calibrateMenuItem, + this.calibrationMenuItem, SEPARATOR_MENU_ITEM, this.overlayMenuItem, this.statisticsMenuItem, @@ -396,7 +433,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { private mouseCoordinateInterpolation?: CoordinateInterpolator get isMouseCoordinateVisible() { - return !!this.mouseCoordinate && !this.mirrorHorizontal && !this.mirrorVertical + return !!this.mouseCoordinate && !this.transformation.mirrorHorizontal + && !this.transformation.mirrorVertical } constructor( @@ -411,9 +449,51 @@ export class ImageComponent implements AfterViewInit, OnDestroy { ) { app.title = 'Image' + app.topMenu.push({ + icon: 'mdi mdi-fullscreen', + label: 'Fullscreen', + command: () => this.enterFullscreen(), + }) + + app.topMenu.push({ + icon: 'mdi mdi-minus', + label: 'Zoom Out', + command: () => this.zoomOut(), + }) + + app.topMenu.push({ + icon: 'mdi mdi-plus', + label: 'Zoom In', + command: () => this.zoomIn(), + }) + + app.topMenu.push({ + icon: 'mdi mdi-numeric-0', + label: 'Reset Zoom', + command: () => this.resetZoom(false), + }) + + app.topMenu.push({ + icon: 'mdi mdi-fit-to-screen', + label: 'Fit to Screen', + command: () => this.resetZoom(true), + }) + + this.stretchShadow.subscribe(value => { + this.stretch.shadow = value / 65536 + }) + + this.stretchHighlight.subscribe(value => { + this.stretch.highlight = value / 65536 + }) + + this.stretchMidtone.subscribe(value => { + this.stretch.midtone = value / 65536 + }) + electron.on('CAMERA.CAPTURE_ELAPSED', async (event) => { if (event.state === 'EXPOSURE_FINISHED' && event.camera.id === this.imageData.camera?.id) { - await this.closeImage(event.savePath !== this.imageData.path) + await this.closeImage(true) ngZone.run(() => { this.imageData.path = event.savePath @@ -431,17 +511,27 @@ export class ImageComponent implements AfterViewInit, OnDestroy { }) }) + electron.on('CALIBRATION.CHANGED', () => { + ngZone.run(() => { + this.loadCalibrationGroups() + }) + }) + hotkeys('ctrl+a', (event) => { event.preventDefault(); this.toggleStretch() }) hotkeys('ctrl+i', (event) => { event.preventDefault(); this.invertImage() }) hotkeys('ctrl+x', (event) => { event.preventDefault(); this.toggleCrosshair() }) hotkeys('ctrl+-', (event) => { event.preventDefault(); this.zoomOut() }) hotkeys('ctrl+=', (event) => { event.preventDefault(); this.zoomIn() }) hotkeys('ctrl+0', (event) => { event.preventDefault(); this.resetZoom() }) + hotkeys('f12', (event) => { if (this.app.showTopBar) { event.preventDefault(); this.enterFullscreen() } }) + hotkeys('escape', (event) => { if (!this.app.showTopBar) { event.preventDefault(); this.exitFullscreen() } }) this.loadPreference() } - ngAfterViewInit() { + async ngAfterViewInit() { + await this.loadCalibrationGroups() + this.route.queryParams.subscribe(e => { const data = JSON.parse(decodeURIComponent(e.data)) as ImageData this.loadImageFromData(data) @@ -455,6 +545,77 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.roiInteractable?.unset() } + private markCalibrationGroupItem(name?: string) { + this.calibrationMenuItem.items![1].checked = this.calibrationViaCamera + + for (let i = 3; i < this.calibrationMenuItem.items!.length; i++) { + const item = this.calibrationMenuItem.items![i] + item.checked = item.label === (name ?? 'None') + item.disabled = this.calibrationViaCamera + } + } + + private async loadCalibrationGroups() { + const groups = await this.api.calibrationGroups() + const found = !!groups.find(e => this.transformation.calibrationGroup === e) + let reloadImage = false + + if (!found) { + reloadImage = !!this.transformation.calibrationGroup + this.transformation.calibrationGroup = undefined + this.calibrationViaCamera = true + } + + const makeItem = (name?: string) => { + const label = name ?? 'None' + const icon = name ? 'mdi mdi-wrench' : 'mdi mdi-close' + + return { + label, icon, + checked: this.transformation.calibrationGroup === name, + disabled: this.calibrationViaCamera, + command: async () => { + this.transformation.calibrationGroup = name + this.markCalibrationGroupItem(label) + await this.loadImage() + }, + } + } + + const menu: ExtendedMenuItem[] = [] + + menu.push({ + label: 'Open', + icon: 'mdi mdi-wrench', + command: () => this.browserWindow.openCalibration() + }) + + menu.push({ + label: 'Camera', + icon: 'mdi mdi-camera-iris', + checked: this.calibrationViaCamera, + command: () => { + this.calibrationViaCamera = !this.calibrationViaCamera + this.markCalibrationGroupItem(this.transformation.calibrationGroup) + } + }) + + menu.push(SEPARATOR_MENU_ITEM) + menu.push(makeItem()) + + for (const group of groups) { + menu.push(makeItem(group)) + } + + this.calibrationMenuItem.items = menu + this.menu.model = this.contextMenuItems + this.menu.cd.markForCheck() + + if (reloadImage) { + this.loadImage() + } + } + private async closeImage(force: boolean = false) { if (this.imageData.path) { if (force) { @@ -483,10 +644,10 @@ export class ImageComponent implements AfterViewInit, OnDestroy { target.setAttribute('data-y', y) this.ngZone.run(() => { - this.roiX = Math.round(x) - this.roiY = Math.round(y) - this.roiWidth = Math.round(event.rect.width / scale) - this.roiHeight = Math.round(event.rect.height / scale) + this.imageROI.x = Math.round(x) + this.imageROI.y = Math.round(y) + this.imageROI.width = Math.round(event.rect.width / scale) + this.imageROI.height = Math.round(event.rect.height / scale) }) } @@ -504,8 +665,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { target.setAttribute('data-y', y) this.ngZone.run(() => { - this.roiX = Math.round(x) - this.roiY = Math.round(y) + this.imageROI.x = Math.round(x) + this.imageROI.y = Math.round(y) }) } @@ -514,19 +675,23 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.imageData = data + // Not clicked on menu item. + if (this.calibrationViaCamera && this.transformation.calibrationGroup !== data.capture?.calibrationGroup) { + this.transformation.calibrationGroup = data.capture?.calibrationGroup + this.markCalibrationGroupItem(this.transformation.calibrationGroup) + } + if (data.source === 'FRAMING') { this.disableAutoStretch() - this.resetStretch(false) - } else if (data.source === 'FLAT_WIZARD') { - this.disableCalibrate(false) - } - if (!data.camera) { - this.disableCalibrate() + if (this.transformation.stretch.auto) { + this.resetStretch(false) + } + } else if (data.source === 'FLAT_WIZARD') { + this.disableCalibration(false) } this.clearOverlay() - this.loadImage() } @@ -535,11 +700,11 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.annotationIsVisible = false this.annotationMenuItem.toggleable = false - this.detectedStars = [] - this.detectedStarsIsVisible = false + this.detectedStars.stars = [] + this.detectedStars.visible = false this.detectStarsMenuItem.toggleable = false - Object.assign(this.imageSolved, EMPTY_IMAGE_SOLVED) + Object.assign(this.solver.solved, EMPTY_IMAGE_SOLVED) this.histogram?.update([]) } @@ -563,7 +728,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } else if (this.imageData.camera) { this.app.subTitle = this.imageData.camera.name } else if (this.imageData.path) { - this.app.subTitle = path.basename(this.imageData.path) + this.app.subTitle = basename(this.imageData.path) } else { this.app.subTitle = '' } @@ -571,31 +736,37 @@ export class ImageComponent implements AfterViewInit, OnDestroy { private async loadImageFromPath(path: string) { const image = this.image.nativeElement - const scnrEnabled = this.scnrChannel !== 'NONE' - const { info, blob } = await this.api.openImage(path, this.imageData.camera, this.calibrate, this.debayer, !!this.imageData.camera, this.autoStretched, - this.stretchShadowHighlight[0] / 65536, this.stretchShadowHighlight[1] / 65536, this.stretchMidtone / 65536, - this.mirrorHorizontal, this.mirrorVertical, this.invert, scnrEnabled, scnrEnabled ? this.scnrChannel : 'GREEN', this.scnrAmount, this.scnrProtectionMethod) + + const transformation = structuredClone(this.transformation) + if (this.calibrationViaCamera) transformation.calibrationGroup = this.imageData.capture?.calibrationGroup + const { info, blob } = await this.api.openImage(path, transformation, this.imageData.camera) this.imageInfo = info this.scnrMenuItem.disabled = info.mono - if (info.rightAscension) this.solverCenterRA = info.rightAscension - if (info.declination) this.solverCenterDEC = info.declination - this.solverBlind = !this.solverCenterRA || !this.solverCenterDEC + if (info.rightAscension) this.solver.centerRA = info.rightAscension + if (info.declination) this.solver.centerDEC = info.declination + this.solver.blind = !this.solver.centerRA || !this.solver.centerDEC - if (this.autoStretched) { - this.stretchShadowHighlight = [Math.trunc(info.stretchShadow * 65536), Math.trunc(info.stretchHighlight * 65536)] - this.stretchMidtone = Math.trunc(info.stretchMidtone * 65536) + if (this.stretch.auto) { + this.stretchShadow.set(Math.trunc(info.stretchShadow * 65536)) + this.stretchHighlight.set(Math.trunc(info.stretchHighlight * 65536)) + this.stretchMidtone.set(Math.trunc(info.stretchMidtone * 65536)) } this.updateImageSolved(info.solved) - this.fitsHeaders = info.headers + this.fitsHeaders.headers = info.headers if (this.imageURL) window.URL.revokeObjectURL(this.imageURL) this.imageURL = window.URL.createObjectURL(blob) image.src = this.imageURL + if (!info.camera?.id) { + this.calibrationViaCamera = false + this.markCalibrationGroupItem(this.transformation.calibrationGroup) + } + this.retrieveCoordinateInterpolation() } @@ -623,15 +794,20 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } } + async saveImageAs() { + await this.api.saveImageAs(this.imageData!.path!, 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.annotateWithStarsAndDSOs, this.annotateWithMinorPlanets, this.annotateWithMinorPlanetsMagLimit) + this.annotations = await this.api.annotationsOfImage(this.imageData.path!, this.annotation.useStarsAndDSOs, + this.annotation.useMinorPlanets, this.annotation.minorPlanetsMagLimit, this.annotation.useSimbad) this.annotationIsVisible = true this.annotationMenuItem.toggleable = this.annotations.length > 0 this.annotationMenuItem.toggled = this.annotationMenuItem.toggleable - this.showAnnotationDialog = false + this.annotation.showDialog = false } finally { this.annotating = false } @@ -643,26 +819,27 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } private disableAutoStretch() { - this.autoStretched = false + this.stretch.auto = false this.autoStretchMenuItem.checked = false } - private disableCalibrate(canEnable: boolean = true) { - this.calibrate = false - this.calibrateMenuItem.checked = false - this.calibrateMenuItem.disabled = !canEnable + private disableCalibration(canEnable: boolean = true) { + this.transformation.calibrationGroup = undefined + this.markCalibrationGroupItem(undefined) + this.calibrationMenuItem.disabled = !canEnable } autoStretch() { - this.autoStretched = true + this.stretch.auto = true this.autoStretchMenuItem.checked = true this.loadImage() } resetStretch(load: boolean = true) { - this.stretchShadowHighlight = [0, 65536] - this.stretchMidtone = 32768 + this.stretchShadow.set(0) + this.stretchHighlight.set(65536) + this.stretchMidtone.set(32768) if (load) { this.stretchImage() @@ -670,10 +847,10 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } toggleStretch() { - this.autoStretched = !this.autoStretched - this.autoStretchMenuItem.checked = this.autoStretched + this.stretch.auto = !this.stretch.auto + this.autoStretchMenuItem.checked = this.stretch.auto - if (!this.autoStretched) { + if (!this.stretch.auto) { this.resetStretch() } else { this.loadImage() @@ -686,8 +863,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } invertImage() { - this.invert = !this.invert - this.invertMenuItem.checked = this.invert + this.transformation.invert = !this.transformation.invert + this.invertMenuItem.checked = this.transformation.invert this.loadImage() } @@ -712,9 +889,31 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.panZoom.smoothZoomAbs(window.innerWidth / 2, window.innerHeight / 2, scale * 0.9) } - resetZoom() { - if (!this.panZoom) return - this.panZoom.smoothZoomAbs(window.innerWidth / 2, window.innerHeight / 2, 1.0) + center() { + const { width, height } = this.image.nativeElement.getBoundingClientRect() + this.panZoom?.moveTo(window.innerWidth / 2 - width / 2, (window.innerHeight - 42) / 2 - height / 2) + } + + resetZoom(fitToScreen: boolean = false, center: boolean = true) { + if (fitToScreen) { + const { width, height } = this.image.nativeElement + const factor = Math.min(window.innerWidth, window.innerHeight - 42) / Math.min(width, height) + this.panZoom?.smoothZoomAbs(window.innerWidth / 2, window.innerHeight / 2, factor) + } else { + this.panZoom?.smoothZoomAbs(window.innerWidth / 2, window.innerHeight / 2, 1.0) + } + + if (center) { + this.center() + } + } + + async enterFullscreen() { + this.app.showTopBar = !await this.electron.fullscreenWindow(true) + } + + async exitFullscreen() { + this.app.showTopBar = !await this.electron.fullscreenWindow(false) } private async retrieveCoordinateInterpolation() { @@ -733,33 +932,32 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } async solveImage() { - this.solving = true + this.solver.solving = true try { - const options = this.preference.plateSolverOptions(this.solverType).get() - const solved = await this.api.solveImage(options, this.imageData.path!, this.solverBlind, - this.solverCenterRA, this.solverCenterDEC, this.solverRadius) + const solver = this.preference.plateSolverPreference(this.solver.type).get() + const solved = await this.api.solveImage(solver, this.imageData.path!, this.solver.blind, + this.solver.centerRA, this.solver.centerDEC, this.solver.radius) this.savePreference() this.updateImageSolved(solved) } catch { this.updateImageSolved(this.imageInfo?.solved) } finally { - this.solving = false + this.solver.solving = false this.retrieveCoordinateInterpolation() } } private updateImageSolved(solved?: ImageSolved) { - this.solved = !!solved - Object.assign(this.imageSolved, solved ?? EMPTY_IMAGE_SOLVED) - this.annotationMenuItem.disabled = !this.solved - this.fovMenuItem.disabled = !this.solved - this.pointMountHereMenuItem.disabled = !this.solved - this.frameAtThisCoordinateMenuItem.disabled = !this.solved + Object.assign(this.solver.solved, solved ?? EMPTY_IMAGE_SOLVED) + this.annotationMenuItem.disabled = !this.solver.solved.solved + this.fovMenuItem.disabled = !this.solver.solved.solved + this.pointMountHereMenuItem.disabled = !this.solver.solved.solved + this.frameAtThisCoordinateMenuItem.disabled = !this.solver.solved.solved - if (solved) this.fovs.forEach(e => this.computeFOV(e)) - else this.fovs.forEach(e => e.computed = undefined) + if (solved) this.fov.fovs.forEach(e => this.computeFOV(e)) + else this.fov.fovs.forEach(e => e.computed = undefined) } mountSync(coordinate: EquatorialCoordinateJ2000) { @@ -781,7 +979,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } frame(coordinate: EquatorialCoordinateJ2000) { - this.browserWindow.openFraming({ data: { rightAscension: coordinate.rightAscensionJ2000, declination: coordinate.declinationJ2000, fov: this.imageSolved!.width / 60, rotation: this.imageSolved!.orientation } }) + this.browserWindow.openFraming({ data: { rightAscension: coordinate.rightAscensionJ2000, declination: coordinate.declinationJ2000, fov: this.solver.solved!.width / 60, rotation: this.solver.solved!.orientation } }) } imageLoaded() { @@ -805,69 +1003,69 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } async showFOVCameras() { - if (!this.fovCameras.length) { - this.fovCameras = await this.api.fovCameras() + if (!this.fov.cameras.length) { + this.fov.cameras = await this.api.fovCameras() } - this.fovCamera = undefined - this.showFOVCamerasDialog = true + this.fov.camera = undefined + this.fov.showCameraDialog = true } async showFOVTelescopes() { - if (!this.fovTelescopes.length) { - this.fovTelescopes = await this.api.fovTelescopes() + if (!this.fov.telescopes.length) { + this.fov.telescopes = await this.api.fovTelescopes() } - this.fovTelescope = undefined - this.showFOVTelescopesDialog = true + this.fov.telescope = undefined + this.fov.showTelescopeDialog = true } chooseCamera() { - if (this.fovCamera) { - this.fov.cameraSize.width = this.fovCamera.width - this.fov.cameraSize.height = this.fovCamera.height - this.fov.pixelSize.width = this.fovCamera.pixelSize - this.fov.pixelSize.height = this.fovCamera.pixelSize - this.fovCamera = undefined - this.showFOVCamerasDialog = false + if (this.fov.camera) { + this.fov.cameraSize.width = this.fov.camera.width + this.fov.cameraSize.height = this.fov.camera.height + this.fov.pixelSize.width = this.fov.camera.pixelSize + this.fov.pixelSize.height = this.fov.camera.pixelSize + this.fov.camera = undefined + this.fov.showCameraDialog = false } } chooseTelescope() { - if (this.fovTelescope) { - this.fov.aperture = this.fovTelescope.aperture - this.fov.focalLength = this.fovTelescope.focalLength - this.fovTelescope = undefined - this.showFOVTelescopesDialog = false + if (this.fov.telescope) { + this.fov.aperture = this.fov.telescope.aperture + this.fov.focalLength = this.fov.telescope.focalLength + this.fov.telescope = undefined + this.fov.showTelescopeDialog = false } } addFOV() { if (this.computeFOV(this.fov)) { - this.fovs.push(structuredClone(this.fov)) - this.preference.imageFOVs.set(this.fovs) + this.fov.fovs.push(structuredClone(this.fov)) + this.preference.imageFOVs.set(this.fov.fovs) } } editFOV(fov: FOV) { Object.assign(this.fov, structuredClone(fov)) - this.editedFOV = fov + this.fov.edited = fov } cancelEditFOV() { - this.editedFOV = undefined + this.fov.edited = undefined } saveFOV() { - if (this.editedFOV && this.computeFOV(this.fov)) { - Object.assign(this.editedFOV, structuredClone(this.fov)) - this.preference.imageFOVs.set(this.fovs) - this.editedFOV = undefined + if (this.fov.edited && this.computeFOV(this.fov)) { + Object.assign(this.fov.edited, structuredClone(this.fov)) + this.preference.imageFOVs.set(this.fov.fovs) + this.fov.edited = undefined } } private computeFOV(fov: FOV) { - if (this.imageInfo && this.imageSolved.scale > 0) { + if (this.imageInfo && this.solver.solved.scale > 0) { const focalLength = fov.focalLength * (fov.barlowReducer || 1) const resolution = { @@ -878,8 +1076,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { const svg = { x: this.imageInfo.width / 2, y: this.imageInfo.height / 2, - width: fov.cameraSize.width * (resolution.width / this.imageSolved.scale), - height: fov.cameraSize.height * (resolution.height / this.imageSolved.scale), + width: fov.cameraSize.width * (resolution.width / this.solver.solved.scale), + height: fov.cameraSize.height * (resolution.height / this.solver.solved.scale), } svg.x += (this.imageInfo.width - svg.width) / 2 @@ -907,32 +1105,30 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } deleteFOV(fov: FOV) { - const index = this.fovs.indexOf(fov) + const index = this.fov.fovs.indexOf(fov) if (index >= 0) { - if (this.fovs[index] === this.editedFOV) { - this.editedFOV = undefined + if (this.fov.fovs[index] === this.fov.edited) { + this.fov.edited = undefined } - this.fovs.splice(index, 1) - this.preference.imageFOVs.set(this.fovs) + this.fov.fovs.splice(index, 1) + this.preference.imageFOVs.set(this.fov.fovs) } } private loadPreference() { const preference = this.preference.imagePreference.get() - this.solverRadius = preference.solverRadius ?? this.solverRadius - this.solverType = preference.solverType ?? this.solverTypes[0] - this.fovs = this.preference.imageFOVs.get() - this.fovs.forEach(e => { e.enabled = false; e.computed = undefined }) + this.solver.radius = preference.solverRadius ?? this.solver.radius + this.solver.type = preference.solverType ?? this.solver.types[0] + this.fov.fovs = this.preference.imageFOVs.get() + this.fov.fovs.forEach(e => { e.enabled = false; e.computed = undefined }) } private savePreference() { - const preference: ImagePreference = { - solverRadius: this.solverRadius, - solverType: this.solverType - } - + const preference = this.preference.imagePreference.get() + preference.solverRadius = this.solver.radius + preference.solverType = this.solver.type this.preference.imagePreference.set(preference) } @@ -949,7 +1145,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } else { const mount = await this.deviceMenu.show(mounts) - if (mount && mount.connected) { + if (mount && mount !== 'NONE' && mount.connected) { action(mount) return true } diff --git a/desktop/src/app/indi/indi.component.ts b/desktop/src/app/indi/indi.component.ts index 13d2591cb..7469cd37b 100644 --- a/desktop/src/app/indi/indi.component.ts +++ b/desktop/src/app/indi/indi.component.ts @@ -72,7 +72,7 @@ export class INDIComponent implements AfterViewInit, OnDestroy { this.route.queryParams.subscribe(e => { const device = JSON.parse(decodeURIComponent(e.data)) - if ("name" in device && device.name) { + if ("id" in device && device.id) { this.device = device } }) diff --git a/desktop/src/app/mount/mount.component.html b/desktop/src/app/mount/mount.component.html index ff2761b52..0c32f55fc 100644 --- a/desktop/src/app/mount/mount.component.html +++ b/desktop/src/app/mount/mount.component.html @@ -10,6 +10,10 @@
+
+ +
@@ -218,5 +222,54 @@
- - \ No newline at end of file + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ +
+
+ + +
+
+ {{ item.type }} + {{ item.host }}:{{ item.port }} +
+
+ +
+
+
+
+
+
+ + +
+ + + + \ No newline at end of file diff --git a/desktop/src/app/mount/mount.component.ts b/desktop/src/app/mount/mount.component.ts index c706074c3..d1ea98a65 100644 --- a/desktop/src/app/mount/mount.component.ts +++ b/desktop/src/app/mount/mount.component.ts @@ -7,8 +7,9 @@ import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' import { ElectronService } from '../../shared/services/electron.service' import { LocalStorageService } from '../../shared/services/local-storage.service' +import { PrimeService } from '../../shared/services/prime.service' import { Angle, ComputedLocation, Constellation, EMPTY_COMPUTED_LOCATION } from '../../shared/types/atlas.types' -import { EMPTY_MOUNT, Mount, PierSide, SlewRate, TargetCoordinateType, TrackMode } from '../../shared/types/mount.types' +import { EMPTY_MOUNT, Mount, MountRemoteControlDialog, MountRemoteControlType, PierSide, SlewRate, TargetCoordinateType, TrackMode } from '../../shared/types/mount.types' import { AppComponent } from '../app.component' import { SkyAtlasTab } from '../atlas/atlas.component' @@ -121,7 +122,7 @@ export class MountComponent implements AfterContentInit, OnDestroy { { icon: 'mdi mdi-crosshairs-gps', label: 'Locations', - items: [ + menu: [ { icon: 'mdi mdi-crosshairs-gps', label: 'Current location', @@ -175,7 +176,7 @@ export class MountComponent implements AfterContentInit, OnDestroy { { icon: 'mdi mdi-crosshairs', label: 'Intersection points', - items: [ + menu: [ { icon: 'mdi mdi-crosshairs-gps', label: 'Meridian x Equator', @@ -192,6 +193,14 @@ export class MountComponent implements AfterContentInit, OnDestroy { this.updateTargetCoordinate(coordinates) }, }, + { + icon: 'mdi mdi-crosshairs-gps', + label: 'Equator x Ecliptic', + command: async () => { + const coordinates = await this.api.mountCelestialLocation(this.mount, 'EQUATOR_ECLIPTIC') + this.updateTargetCoordinate(coordinates) + }, + }, ] }, ], @@ -200,6 +209,14 @@ export class MountComponent implements AfterContentInit, OnDestroy { targetCoordinateOption = this.targetCoordinateModel[0] + readonly remoteControl: MountRemoteControlDialog = { + showDialog: false, + type: 'LX200', + host: '0.0.0.0', + port: 10001, + data: [], + } + constructor( private app: AppComponent, private api: ApiService, @@ -207,6 +224,7 @@ export class MountComponent implements AfterContentInit, OnDestroy { private electron: ElectronService, private storage: LocalStorageService, private route: ActivatedRoute, + private prime: PrimeService, ngZone: NgZone, ) { app.title = 'Mount' @@ -264,8 +282,8 @@ export class MountComponent implements AfterContentInit, OnDestroy { } async mountChanged(mount?: Mount) { - if (mount && mount.name) { - mount = await this.api.mount(mount.name) + if (mount && mount.id) { + mount = await this.api.mount(mount.id) Object.assign(this.mount, mount) this.loadPreference() @@ -285,6 +303,25 @@ export class MountComponent implements AfterContentInit, OnDestroy { } } + async showRemoteControlDialog() { + this.remoteControl.data = await this.api.mountRemoteControlList(this.mount) + this.remoteControl.showDialog = true + } + + async startRemoteControl() { + try { + await this.api.mountRemoteControlStart(this.mount, this.remoteControl.type, this.remoteControl.host, this.remoteControl.port) + this.remoteControl.data = await this.api.mountRemoteControlList(this.mount) + } catch { + this.prime.message('Failed to start remote control', 'error') + } + } + + async stopRemoteControl(type: MountRemoteControlType) { + await this.api.mountRemoteControlStop(this.mount, type) + this.remoteControl.data = await this.api.mountRemoteControlList(this.mount) + } + async goTo() { await this.api.mountGoTo(this.mount, this.targetRightAscension, this.targetDeclination, this.targetCoordinateType === 'J2000') this.savePreference() @@ -391,7 +428,7 @@ export class MountComponent implements AfterContentInit, OnDestroy { } private update() { - if (this.mount.name) { + if (this.mount.id) { this.slewing = this.mount.slewing this.parking = this.mount.parking this.parked = this.mount.parked @@ -445,7 +482,7 @@ export class MountComponent implements AfterContentInit, OnDestroy { } private loadPreference() { - if (this.mount.name) { + if (this.mount.id) { const preference = this.storage.get(mountPreferenceKey(this.mount), {}) this.targetCoordinateType = preference.targetCoordinateType ?? 'JNOW' this.targetRightAscension = preference.targetRightAscension ?? '00h00m00s' diff --git a/desktop/src/app/rotator/rotator.component.html b/desktop/src/app/rotator/rotator.component.html new file mode 100644 index 000000000..845f17a58 --- /dev/null +++ b/desktop/src/app/rotator/rotator.component.html @@ -0,0 +1,50 @@ +
+
+
+ + + + + + +
+
+
+ + {{ moving ? 'moving' : 'idle' }} +
+
+
+
+
+ + + + +
+
+ + +
+ Reversed + +
+
+
+ + + + + + +
+
+
\ No newline at end of file diff --git a/desktop/src/app/rotator/rotator.component.scss b/desktop/src/app/rotator/rotator.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/desktop/src/app/rotator/rotator.component.ts b/desktop/src/app/rotator/rotator.component.ts new file mode 100644 index 000000000..20843f897 --- /dev/null +++ b/desktop/src/app/rotator/rotator.component.ts @@ -0,0 +1,132 @@ +import { AfterViewInit, Component, HostListener, NgZone, OnDestroy } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { ApiService } from '../../shared/services/api.service' +import { ElectronService } from '../../shared/services/electron.service' +import { PreferenceService } from '../../shared/services/preference.service' +import { EMPTY_ROTATOR, Rotator } from '../../shared/types/rotator.types' +import { AppComponent } from '../app.component' + +@Component({ + selector: 'app-rotator', + templateUrl: './rotator.component.html', + styleUrls: ['./rotator.component.scss'], +}) +export class RotatorComponent implements AfterViewInit, OnDestroy { + + readonly rotator = structuredClone(EMPTY_ROTATOR) + + moving = false + reversed = false + angle = 0 + + constructor( + private app: AppComponent, + private api: ApiService, + electron: ElectronService, + private preference: PreferenceService, + private route: ActivatedRoute, + ngZone: NgZone, + ) { + app.title = 'Rotator' + + electron.on('ROTATOR.UPDATED', event => { + if (event.device.id === this.rotator.id) { + ngZone.run(() => { + Object.assign(this.rotator, event.device) + this.update() + }) + } + }) + + electron.on('ROTATOR.DETACHED', event => { + if (event.device.id === this.rotator.id) { + ngZone.run(() => { + Object.assign(this.rotator, EMPTY_ROTATOR) + }) + } + }) + } + + async ngAfterViewInit() { + this.route.queryParams.subscribe(e => { + const rotator = JSON.parse(decodeURIComponent(e.data)) as Rotator + this.rotatorChanged(rotator) + }) + } + + @HostListener('window:unload') + ngOnDestroy() { + this.abort() + } + + async rotatorChanged(rotator?: Rotator) { + if (rotator && rotator.id) { + rotator = await this.api.rotator(rotator.id) + Object.assign(this.rotator, rotator) + + this.loadPreference() + this.update() + } + + if (this.app) { + this.app.subTitle = rotator?.name ?? '' + } + } + + connect() { + if (this.rotator.connected) { + this.api.rotatorDisconnect(this.rotator) + } else { + this.api.rotatorConnect(this.rotator) + } + } + + reverse(enabled: boolean) { + this.api.focuserReverse(this.rotator, enabled) + } + + async move() { + if (!this.moving) { + this.moving = true + await this.api.rotatorMove(this.rotator, this.angle) + this.savePreference() + } + } + + async sync() { + if (!this.moving) { + await this.api.rotatorSync(this.rotator, this.angle) + this.savePreference() + } + } + + abort() { + this.api.rotatorAbort(this.rotator) + } + + home() { + this.api.rotatorHome(this.rotator) + } + + private update() { + if (this.rotator.id) { + this.moving = this.rotator.moving + this.reversed = this.rotator.reversed + } + } + + private loadPreference() { + if (this.rotator.id) { + const preference = this.preference.rotatorPreference(this.rotator).get() + this.angle = preference.angle ?? 0 + } + } + + private savePreference() { + if (this.rotator.connected) { + const preference = this.preference.rotatorPreference(this.rotator).get() + preference.angle = this.angle + this.preference.rotatorPreference(this.rotator).set(preference) + } + } +} \ No newline at end of file diff --git a/desktop/src/app/sequencer/sequencer.component.html b/desktop/src/app/sequencer/sequencer.component.html index 69b4e197e..c0d2c9ed8 100644 --- a/desktop/src/app/sequencer/sequencer.component.html +++ b/desktop/src/app/sequencer/sequencer.component.html @@ -135,9 +135,10 @@
- - - + + + +
@@ -189,7 +190,7 @@ - +
@@ -292,4 +293,4 @@
- \ No newline at end of file + \ No newline at end of file diff --git a/desktop/src/app/sequencer/sequencer.component.ts b/desktop/src/app/sequencer/sequencer.component.ts index 48b1703ab..9833a323f 100644 --- a/desktop/src/app/sequencer/sequencer.component.ts +++ b/desktop/src/app/sequencer/sequencer.component.ts @@ -9,9 +9,10 @@ import { ElectronService } from '../../shared/services/electron.service' import { LocalStorageService } from '../../shared/services/local-storage.service' import { PrimeService } from '../../shared/services/prime.service' import { JsonFile } from '../../shared/types/app.types' -import { Camera, CameraCaptureElapsed, CameraStartCapture, updateCameraStartCaptureFromCamera } from '../../shared/types/camera.types' +import { Camera, CameraCaptureEvent, CameraStartCapture } from '../../shared/types/camera.types' import { Focuser } from '../../shared/types/focuser.types' -import { EMPTY_SEQUENCE_PLAN, SEQUENCE_ENTRY_PROPERTIES, SequenceCaptureMode, SequenceEntryProperty, SequencePlan, SequencerElapsed } from '../../shared/types/sequencer.types' +import { Mount } from '../../shared/types/mount.types' +import { EMPTY_SEQUENCE_PLAN, SEQUENCE_ENTRY_PROPERTIES, SequenceCaptureMode, SequenceEntryProperty, SequencePlan, SequencerEvent } from '../../shared/types/sequencer.types' import { FilterWheel } from '../../shared/types/wheel.types' import { deviceComparator } from '../../shared/utils/comparators' import { AppComponent } from '../app.component' @@ -29,10 +30,12 @@ export const SEQUENCER_PLAN_KEY = 'sequencer.plan' export class SequencerComponent implements AfterContentInit, OnDestroy { cameras: Camera[] = [] + mounts: Mount[] = [] wheels: FilterWheel[] = [] focusers: Focuser[] = [] camera?: Camera + mount?: Mount wheel?: FilterWheel focuser?: Focuser @@ -86,9 +89,9 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { }, ] - readonly sequenceEvents: CameraCaptureElapsed[] = [] + readonly sequenceEvents: CameraCaptureEvent[] = [] - event?: SequencerElapsed + event?: SequencerEvent running = false @ViewChildren('cameraExposure') @@ -181,6 +184,16 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { }) }) + electron.on('MOUNT.UPDATED', event => { + ngZone.run(() => { + const mount = this.mounts.find(e => e.id === event.device.id) + + if (mount) { + Object.assign(mount, event.device) + } + }) + }) + electron.on('WHEEL.UPDATED', event => { ngZone.run(() => { const wheel = this.wheels.find(e => e.id === event.device.id) @@ -211,12 +224,10 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { this.running = event.remainingTime > 0 const captureEvent = event.capture - const index = event.id - 1 if (captureEvent) { + const index = event.id - 1 this.cameraExposures.get(index)?.handleCameraCaptureEvent(captureEvent) - } else if (!this.running && index >= 0) { - // this.state[index] = undefined } }) }) @@ -228,6 +239,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { async ngAfterContentInit() { this.cameras = (await this.api.cameras()).sort(deviceComparator) + this.mounts = (await this.api.mounts()).sort(deviceComparator) this.wheels = (await this.api.wheels()).sort(deviceComparator) this.focusers = (await this.api.focusers()).sort(deviceComparator) @@ -310,21 +322,13 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { } } - updateEntryFromCamera(entry: CameraStartCapture, camera?: Camera) { - if (camera) { - if (camera.connected) { - updateCameraStartCaptureFromCamera(entry, camera) - this.savePlan() - } - } - } - private loadPlan(plan?: SequencePlan) { plan ??= this.storage.get(SEQUENCER_PLAN_KEY, this.plan) Object.assign(this.plan, structuredClone(plan)) this.camera = this.cameras.find(e => e.name === this.plan.camera?.name) ?? this.cameras[0] + this.mount = this.mounts.find(e => e.name === this.plan.mount?.name) ?? this.mounts[0] this.focuser = this.focusers.find(e => e.name === this.plan.focuser?.name) ?? this.focusers[0] this.wheel = this.wheels.find(e => e.name === this.plan.wheel?.name) ?? this.wheels[0] @@ -370,6 +374,7 @@ export class SequencerComponent implements AfterContentInit, OnDestroy { savePlan() { this.plan.camera = this.camera + this.plan.mount = this.mount this.plan.wheel = this.wheel this.plan.focuser = this.focuser this.storage.set(SEQUENCER_PLAN_KEY, this.plan) diff --git a/desktop/src/app/settings/settings.component.html b/desktop/src/app/settings/settings.component.html index 6cb984b27..4286a762d 100644 --- a/desktop/src/app/settings/settings.component.html +++ b/desktop/src/app/settings/settings.component.html @@ -1,112 +1,60 @@
-
- - -
- - {{ item.label }} -
-
-
-
-
-
-
- + + +
+ -
-
- - - -
-
-
-
-
-
- - - - -
-
- - - - - - - -
-
-
-
- - - - -
-
- - - - -
-
- - - - -
-
- - - - -
-
-
+
+ + +
-
-
-
- - -
- {{ item.key }} - {{ item.value }} -
-
-
-
-
-
- {{ databaseEntry.key }} - -
- - {{ databaseEntry.value }} + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
-
-
+ +
\ 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 428981c3c..4567d72ce 100644 --- a/desktop/src/app/settings/settings.component.ts +++ b/desktop/src/app/settings/settings.component.ts @@ -1,14 +1,12 @@ import { AfterViewInit, Component, HostListener, OnDestroy } from '@angular/core' import path from 'path' -import { MenuItem } from 'primeng/api' 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 { DEFAULT_SOLVER_TYPES, DatabaseEntry, PlateSolverOptions, PlateSolverType } from '../../shared/types/settings.types' -import { compareBy, textComparator } from '../../shared/utils/comparators' +import { DEFAULT_SOLVER_TYPES, PlateSolverPreference, PlateSolverType } from '../../shared/types/settings.types' import { AppComponent } from '../app.component' @Component({ @@ -23,27 +21,7 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { readonly solverTypes = Array.from(DEFAULT_SOLVER_TYPES) solverType = this.solverTypes[0] - readonly solvers = new Map() - - readonly database: DatabaseEntry[] = [] - databaseEntry?: DatabaseEntry - - readonly menu: MenuItem[] = [ - { - icon: 'mdi mdi-map-marker', - label: 'Location', - }, - { - icon: 'mdi mdi-sigma', - label: 'Plate Solver', - }, - { - icon: 'mdi mdi-database', - label: 'Local Storage', - }, - ] - - selectedMenu = this.menu[0] + readonly solvers = new Map() constructor( app: AppComponent, @@ -58,16 +36,8 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { this.location = preference.selectedLocation.get(this.locations[0]) for (const type of this.solverTypes) { - this.solvers.set(type, preference.plateSolverOptions(type).get()) + this.solvers.set(type, preference.plateSolverPreference(type).get()) } - - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i)! - const value = localStorage.getItem(key) - this.database.push({ key, value }) - } - - this.database.sort(compareBy('key', textComparator)) } async ngAfterViewInit() { } @@ -139,19 +109,9 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { } } - deleteDatabaseEntry() { - if (this.databaseEntry) { - localStorage.removeItem(this.databaseEntry.key) - - const index = this.database.indexOf(this.databaseEntry) - this.database.splice(index, 1) - this.databaseEntry = undefined - } - } - save() { for (const type of this.solverTypes) { - this.preference.plateSolverOptions(type).set(this.solvers.get(type)!) + this.preference.plateSolverPreference(type).set(this.solvers.get(type)!) } } } \ No newline at end of file diff --git a/desktop/src/shared/components/camera-exposure/camera-exposure.component.html b/desktop/src/shared/components/camera-exposure/camera-exposure.component.html index 0b4fc1559..c832817ce 100644 --- a/desktop/src/shared/components/camera-exposure/camera-exposure.component.html +++ b/desktop/src/shared/components/camera-exposure/camera-exposure.component.html @@ -1,47 +1,45 @@
- {{ (state ?? 'IDLE') | enum | lowercase }} + {{ (info || state || 'IDLE') | enum | lowercase }} - {{ exposure.count }} / {{ capture.amount }} + {{ capture.count }} / {{ capture.amount }} + @if (!capture.looping) { {{ capture.progress * 100 | number:'1.1-1' }} + } @if (capture.looping) { {{ capture.elapsedTime | exposureTime }} - } @else if(showRemainingTime) { - - {{ capture.remainingTime | exposureTime }} - } @else { - {{ capture.elapsedTime | exposureTime }} + (click)="showRemainingTime = !showRemainingTime"> + + {{ capture.remainingTime | exposureTime }} + + + {{ capture.elapsedTime | exposureTime }} + } - @if (state === 'EXPOSURING') { - - {{ exposure.remainingTime | exposureTime }} + @if (capture.amount !== 1 && (state === 'EXPOSURING' || state === 'WAITING')) { + + + {{ step.remainingTime | exposureTime }} + + + {{ step.elapsedTime | exposureTime }} + - {{ exposure.progress * 100 | number:'1.1-1' }} - - } - - @if (state === 'WAITING') { - - {{ wait.remainingTime | exposureTime }} - - - {{ wait.progress * 100 | number:'1.1-1' }} + {{ step.progress * 100 | number:'1.1-1' }} } diff --git a/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts b/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts index 0115b0389..c25094060 100644 --- a/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts +++ b/desktop/src/shared/components/camera-exposure/camera-exposure.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from '@angular/core' -import { CameraCaptureElapsed, CameraCaptureState, EMPTY_CAMERA_CAPTURE_INFO, EMPTY_CAMERA_EXPOSURE_INFO, EMPTY_CAMERA_WAIT_INFO } from '../../types/camera.types' +import { CameraCaptureEvent, CameraCaptureState, EMPTY_CAMERA_CAPTURE_INFO, EMPTY_CAMERA_STEP_INFO } from '../../types/camera.types' @Component({ selector: 'neb-camera-exposure', @@ -9,35 +9,35 @@ import { CameraCaptureElapsed, CameraCaptureState, EMPTY_CAMERA_CAPTURE_INFO, EM export class CameraExposureComponent { @Input() - state?: CameraCaptureState = 'IDLE' + info?: string @Input() showRemainingTime: boolean = true @Input() - readonly exposure = structuredClone(EMPTY_CAMERA_EXPOSURE_INFO) + readonly step = structuredClone(EMPTY_CAMERA_STEP_INFO) @Input() readonly capture = structuredClone(EMPTY_CAMERA_CAPTURE_INFO) - @Input() - readonly wait = structuredClone(EMPTY_CAMERA_WAIT_INFO) + state?: CameraCaptureState = 'IDLE' - handleCameraCaptureEvent(event: CameraCaptureElapsed, looping: boolean = false) { + handleCameraCaptureEvent(event: CameraCaptureEvent, looping: boolean = false) { this.capture.elapsedTime = event.captureElapsedTime this.capture.remainingTime = event.captureRemainingTime this.capture.progress = event.captureProgress - this.exposure.remainingTime = event.exposureRemainingTime - this.exposure.progress = event.exposureProgress - this.exposure.count = event.exposureCount + this.capture.count = event.exposureCount + if (looping) this.capture.looping = looping + this.step.elapsedTime = event.stepElapsedTime + this.step.remainingTime = event.stepRemainingTime + this.step.progress = event.stepProgress if (event.state === 'EXPOSURING') { this.state = 'EXPOSURING' } else if (event.state === 'WAITING') { - this.wait.remainingTime = event.waitRemainingTime - this.wait.progress = event.waitProgress - this.state = event.state - } else if (event.state === 'SETTLING') { + this.step.elapsedTime = event.stepElapsedTime + this.step.remainingTime = event.stepRemainingTime + this.step.progress = event.stepProgress this.state = event.state } else if (event.state === 'CAPTURE_STARTED') { this.capture.looping = looping || event.exposureAmount <= 0 @@ -45,23 +45,19 @@ export class CameraExposureComponent { this.state = 'EXPOSURING' } else if (event.state === 'EXPOSURE_STARTED') { this.state = 'EXPOSURING' - } else if ((!looping && event.state === 'CAPTURE_FINISHED') || (!this.capture.looping && !this.capture.remainingTime)) { - this.state = 'IDLE' + } else if (event.state === 'IDLE' || event.state === 'CAPTURE_FINISHED') { + this.reset() } - return this.state !== undefined && this.state !== 'CAPTURE_FINISHED' - && this.state !== 'IDLE' && !event.aborted + return this.state !== undefined + && this.state !== 'CAPTURE_FINISHED' + && this.state !== 'IDLE' } reset() { this.state = 'IDLE' - Object.assign(this.exposure, EMPTY_CAMERA_EXPOSURE_INFO) + Object.assign(this.step, EMPTY_CAMERA_STEP_INFO) Object.assign(this.capture, EMPTY_CAMERA_CAPTURE_INFO) - Object.assign(this.wait, EMPTY_CAMERA_WAIT_INFO) - } - - toggleRemainingTime() { - this.showRemainingTime = !this.showRemainingTime } } \ No newline at end of file diff --git a/desktop/src/shared/components/device-list-button/device-list-button.component.html b/desktop/src/shared/components/device-list-button/device-list-button.component.html index dfa81f6b4..94bd838b9 100644 --- a/desktop/src/shared/components/device-list-button/device-list-button.component.html +++ b/desktop/src/shared/components/device-list-button/device-list-button.component.html @@ -12,4 +12,4 @@
- \ No newline at end of file + \ No newline at end of file diff --git a/desktop/src/shared/components/device-list-button/device-list-button.component.ts b/desktop/src/shared/components/device-list-button/device-list-button.component.ts index 1b5eb96ef..17c101c42 100644 --- a/desktop/src/shared/components/device-list-button/device-list-button.component.ts +++ b/desktop/src/shared/components/device-list-button/device-list-button.component.ts @@ -21,6 +21,9 @@ export class DeviceListButtonComponent { @Input({ required: true }) readonly devices!: Device[] + @Input() + readonly hasNone: boolean = false + @Input() device?: Device @@ -31,11 +34,11 @@ export class DeviceListButtonComponent { private readonly deviceMenu!: DeviceListMenuComponent async show() { - const device = await this.deviceMenu.show(this.devices) + const device = await this.deviceMenu.show(this.devices, this.device) if (device) { - this.device = device - this.deviceChange.emit(device) + this.device = device === 'NONE' ? undefined : device + this.deviceChange.emit(this.device) } } diff --git a/desktop/src/shared/components/device-list-menu/device-list-menu.component.html b/desktop/src/shared/components/device-list-menu/device-list-menu.component.html index fe3e261e1..132b18f31 100644 --- a/desktop/src/shared/components/device-list-menu/device-list-menu.component.html +++ b/desktop/src/shared/components/device-list-menu/device-list-menu.component.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/desktop/src/shared/components/device-list-menu/device-list-menu.component.ts b/desktop/src/shared/components/device-list-menu/device-list-menu.component.ts index ee95b86a5..a56ad7979 100644 --- a/desktop/src/shared/components/device-list-menu/device-list-menu.component.ts +++ b/desktop/src/shared/components/device-list-menu/device-list-menu.component.ts @@ -5,6 +5,7 @@ import { PrimeService } from '../../services/prime.service' import { Device } from '../../types/device.types' import { deviceComparator } from '../../utils/comparators' import { DialogMenuComponent } from '../dialog-menu/dialog-menu.component' +import { ExtendedMenuItem } from '../menu-item/menu-item.component' @Component({ selector: 'neb-device-list-menu', @@ -22,15 +23,21 @@ export class DeviceListMenuComponent { @Input() readonly disableIfDeviceIsNotConnected: boolean = true + @Input() + header?: string + + @Input() + readonly hasNone: boolean = false + @ViewChild('menu') private readonly menu!: DialogMenuComponent constructor(private prime: PrimeService) { } - show(devices: T[]) { - const model: MenuItem[] = [] + show(devices: T[], selected?: NoInfer) { + const model: ExtendedMenuItem[] = [] - return new Promise((resolve) => { + return new Promise((resolve) => { if (devices.length <= 0) { resolve(undefined) this.prime.message('Please connect your equipment first!', 'warn') @@ -49,10 +56,21 @@ export class DeviceListMenuComponent { model.push(SEPARATOR_MENU_ITEM) } + if (this.hasNone) { + model.push({ + icon: 'mdi mdi-close', + label: 'None', + command: () => { + resolve('NONE') + }, + }) + } + for (const device of devices.sort(deviceComparator)) { model.push({ icon: 'mdi mdi-connection', label: device.name, + checked: selected === device, disabled: this.disableIfDeviceIsNotConnected && !device.connected, command: () => { resolve(device) diff --git a/desktop/src/shared/components/dialog-menu/dialog-menu.component.html b/desktop/src/shared/components/dialog-menu/dialog-menu.component.html index 05cb72c40..b47d4b1e8 100644 --- a/desktop/src/shared/components/dialog-menu/dialog-menu.component.html +++ b/desktop/src/shared/components/dialog-menu/dialog-menu.component.html @@ -1,4 +1,5 @@ - + [dismissableMask]="true" [closable]="true" [draggable]="false" (onHide)="hide()" [style]="{width: 'auto'}"> + {{ header }} + \ No newline at end of file diff --git a/desktop/src/shared/components/dialog-menu/dialog-menu.component.scss b/desktop/src/shared/components/dialog-menu/dialog-menu.component.scss index c73707ea5..9900160b2 100644 --- a/desktop/src/shared/components/dialog-menu/dialog-menu.component.scss +++ b/desktop/src/shared/components/dialog-menu/dialog-menu.component.scss @@ -1,13 +1,5 @@ :host { ::ng-deep { - .p-slidemenu { - background: #272727; - color: rgba(255, 255, 255, 0.87); - border: 0px; - border-radius: 0px; - padding: 12px; - } - .p-menuitem-content { border-radius: 4px; } diff --git a/desktop/src/shared/components/dialog-menu/dialog-menu.component.ts b/desktop/src/shared/components/dialog-menu/dialog-menu.component.ts index 886f20d06..7c811cfb5 100644 --- a/desktop/src/shared/components/dialog-menu/dialog-menu.component.ts +++ b/desktop/src/shared/components/dialog-menu/dialog-menu.component.ts @@ -1,6 +1,6 @@ -import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core' -import { MenuItem } from 'primeng/api' -import { SlideMenu } from 'primeng/slidemenu' +import { Component, EventEmitter, Input, Output } from '@angular/core' +import { ExtendedMenuItem } from '../menu-item/menu-item.component' +import { SlideMenuItemCommandEvent } from '../slide-menu/slide-menu.component' @Component({ selector: 'neb-dialog-menu', @@ -16,14 +16,15 @@ export class DialogMenuComponent { readonly visibleChange = new EventEmitter() @Input() - model: MenuItem[] = [] + model: ExtendedMenuItem[] = [] - @ViewChild('menu') - private readonly menu!: SlideMenu + @Input() + header?: string - viewportHeight = 35 + @Input() + updateHeaderWithMenuLabel: boolean = true - private readonly items: any[][] = [] + private navigationHeader: (string | undefined)[] = [] show() { this.visible = true @@ -32,37 +33,29 @@ export class DialogMenuComponent { hide() { this.visible = false + this.navigationHeader.length = 0 this.visibleChange.emit(false) } - private computeViewportHeightFromProcessedItem() { - const size = this.items[this.items.length - 1].length - - if (size) { - this.viewportHeight = 35 * (size + 1) - } else { + next(event: SlideMenuItemCommandEvent) { + if (!event.item?.menu?.length) { this.hide() - } - } - - protected onShow() { - const onItemClick = this.menu.onItemClick - - this.items.length = 0 - this.items.push(this.menu.processedItems) + } else { + this.navigationHeader.push(this.header) - this.menu.onItemClick = (e) => { - this.items.push(e.processedItem.items) - this.computeViewportHeightFromProcessedItem() - onItemClick.call(this.menu, e) + if (this.updateHeaderWithMenuLabel) { + this.header = event.item?.label + } } + } - const goBack = this.menu.goBack + back() { + if (this.navigationHeader.length) { + const header = this.navigationHeader.splice(this.navigationHeader.length - 1, 1)[0] - this.menu.goBack = (e) => { - this.items.splice(this.items.length - 1, 1) - this.computeViewportHeightFromProcessedItem() - goBack.call(this.menu, e) + if (this.updateHeaderWithMenuLabel) { + this.header = header + } } } } \ No newline at end of file diff --git a/desktop/src/shared/components/menu-item/menu-item.component.html b/desktop/src/shared/components/menu-item/menu-item.component.html index 78e6f5bd4..a1482f4b4 100644 --- a/desktop/src/shared/components/menu-item/menu-item.component.html +++ b/desktop/src/shared/components/menu-item/menu-item.component.html @@ -1,14 +1,13 @@ - +
{{ item.label }}
- @if (item.items?.length) { - - } - @if (item.checked) { - + } @else if(item.toggleable) { - + + } + @if (item.items?.length || item.menu?.length) { + }
\ No newline at end of file diff --git a/desktop/src/shared/components/menu-item/menu-item.component.ts b/desktop/src/shared/components/menu-item/menu-item.component.ts index e13b1f7b4..6edd04381 100644 --- a/desktop/src/shared/components/menu-item/menu-item.component.ts +++ b/desktop/src/shared/components/menu-item/menu-item.component.ts @@ -2,6 +2,10 @@ import { Component, Input } from '@angular/core' import { MenuItem } from 'primeng/api' import { CheckableMenuItem, ToggleableMenuItem } from '../../types/app.types' +export interface ExtendedMenuItem extends MenuItem, Partial, Partial { + menu?: ExtendedMenuItem[] +} + @Component({ selector: 'neb-menu-item', templateUrl: './menu-item.component.html', @@ -10,5 +14,5 @@ import { CheckableMenuItem, ToggleableMenuItem } from '../../types/app.types' export class MenuItemComponent { @Input({ required: true }) - readonly item!: MenuItem | CheckableMenuItem | ToggleableMenuItem + readonly item!: ExtendedMenuItem } \ No newline at end of file diff --git a/desktop/src/shared/components/slide-menu/slide-menu.component.html b/desktop/src/shared/components/slide-menu/slide-menu.component.html new file mode 100644 index 000000000..3cc80d1a2 --- /dev/null +++ b/desktop/src/shared/components/slide-menu/slide-menu.component.html @@ -0,0 +1,8 @@ +
+ + + + + + +
\ No newline at end of file diff --git a/desktop/src/shared/components/slide-menu/slide-menu.component.scss b/desktop/src/shared/components/slide-menu/slide-menu.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/desktop/src/shared/components/slide-menu/slide-menu.component.ts b/desktop/src/shared/components/slide-menu/slide-menu.component.ts new file mode 100644 index 000000000..72bf9bad9 --- /dev/null +++ b/desktop/src/shared/components/slide-menu/slide-menu.component.ts @@ -0,0 +1,73 @@ +import { Component, ElementRef, EventEmitter, Input, OnInit, Output, TemplateRef } from '@angular/core' +import { MenuItemCommandEvent } from 'primeng/api' +import { ExtendedMenuItem } from '../menu-item/menu-item.component' + +export type SlideMenuItem = ExtendedMenuItem +export type SlideMenu = SlideMenuItem[] + +export interface SlideMenuItemCommandEvent extends MenuItemCommandEvent { + item?: SlideMenuItem + parent?: SlideMenuItem + level: number +} + +@Component({ + selector: 'neb-slide-menu', + templateUrl: './slide-menu.component.html', + styleUrls: ['./slide-menu.component.scss'], +}) +export class SlideMenuComponent implements OnInit { + + @Input({ required: true }) + readonly model!: SlideMenu + + @Input() + readonly appendTo: HTMLElement | ElementRef | TemplateRef | string | null | undefined | any + + @Output() + readonly onNext = new EventEmitter() + + @Output() + readonly onBack = new EventEmitter() + + menu!: SlideMenu + private readonly navigation: SlideMenu[] = [] + + ngOnInit() { + this.processMenu(this.model, 0) + this.menu = this.model + } + + back(event: MouseEvent) { + if (this.navigation.length) { + this.menu = this.navigation.splice(this.navigation.length - 1, 1)[0] + this.onBack.emit(undefined) + } + } + + private processMenu(menu: SlideMenu, level: number, parent?: SlideMenuItem) { + for (const item of menu) { + const command = item.command + + if (item.menu?.length) { + item.command = (event: SlideMenuItemCommandEvent) => { + this.menu = item.menu! + this.navigation.push(menu) + event.parent = parent + event.level = level + command?.(event) + this.onNext.emit(event) + } + + this.processMenu(item.menu, level + 1, item) + } else { + item.command = (event: SlideMenuItemCommandEvent) => { + event.parent = parent + event.level = level + command?.(event) + this.onNext.emit(event) + } + } + } + } +} diff --git a/desktop/src/shared/constants.ts b/desktop/src/shared/constants.ts index 0d6e1f0b3..5a2628fdf 100644 --- a/desktop/src/shared/constants.ts +++ b/desktop/src/shared/constants.ts @@ -1,4 +1,4 @@ -import { MenuItem } from 'primeng/api' +import { ExtendedMenuItem } from './components/menu-item/menu-item.component' export const EVERY_MINUTE_CRON_TIME = '0 */1 * * * *' @@ -6,6 +6,6 @@ export const TWO_DIGITS_FORMATTER = new Intl.NumberFormat('en-US', { minimumInte export const THREE_DIGITS_FORMATTER = new Intl.NumberFormat('en-US', { minimumIntegerDigits: 3, minimumFractionDigits: 0, maximumFractionDigits: 0 }) export const ONE_DECIMAL_PLACE_FORMATTER = new Intl.NumberFormat('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) -export const SEPARATOR_MENU_ITEM: MenuItem = { +export const SEPARATOR_MENU_ITEM: ExtendedMenuItem = { separator: true, } diff --git a/desktop/src/shared/pipes/enum.pipe.ts b/desktop/src/shared/pipes/enum.pipe.ts index 48fb830c2..c84525f23 100644 --- a/desktop/src/shared/pipes/enum.pipe.ts +++ b/desktop/src/shared/pipes/enum.pipe.ts @@ -6,7 +6,7 @@ import { GuideState } from '../types/guider.types' import { SCNRProtectionMethod } from '../types/image.types' export type EnumPipeKey = SCNRProtectionMethod | Constellation | SkyObjectType | SatelliteGroupType | - DARVState | TPPAState | GuideState | CameraCaptureState | 'ALL' + DARVState | TPPAState | GuideState | CameraCaptureState | 'ALL' | string @Pipe({ name: 'enum' }) export class EnumPipe implements PipeTransform { @@ -328,6 +328,7 @@ export class EnumPipe implements PipeTransform { 'BACKWARD': 'Backward', 'IDLE': 'Idle', 'SLEWING': 'Slewing', + 'SLEWED': 'Slewed', 'SOLVING': 'Solving', 'SOLVED': 'Solved', 'COMPUTED': 'Computed', diff --git a/desktop/src/shared/services/api.service.ts b/desktop/src/shared/services/api.service.ts index eda61dc42..426feeabc 100644 --- a/desktop/src/shared/services/api.service.ts +++ b/desktop/src/shared/services/api.service.ts @@ -9,11 +9,12 @@ import { FlatWizardRequest } from '../types/flat-wizard.types' import { Focuser } from '../types/focuser.types' import { HipsSurvey } from '../types/framing.types' import { GuideDirection, GuideOutput, Guider, GuiderHistoryStep, SettleInfo } from '../types/guider.types' -import { ConnectionStatus, ConnectionType } from '../types/home.types' -import { CoordinateInterpolation, DetectedStar, FOVCamera, FOVTelescope, ImageAnnotation, ImageChannel, ImageInfo, ImageSolved, SCNRProtectionMethod } from '../types/image.types' -import { CelestialLocationType, Mount, SlewRate, TrackMode } from '../types/mount.types' +import { ConnectionStatus, ConnectionType, Equipment } from '../types/home.types' +import { CoordinateInterpolation, DetectedStar, FOVCamera, FOVTelescope, ImageAnnotation, ImageInfo, ImageSaveDialog, ImageSolved, ImageTransformation } from '../types/image.types' +import { CelestialLocationType, Mount, MountRemoteControl, MountRemoteControlType, SlewRate, TrackMode } from '../types/mount.types' +import { Rotator } from '../types/rotator.types' import { SequencePlan } from '../types/sequencer.types' -import { PlateSolverOptions } from '../types/settings.types' +import { PlateSolverPreference } from '../types/settings.types' import { FilterWheel } from '../types/wheel.types' import { HttpService } from './http.service' @@ -51,42 +52,43 @@ export class ApiService { return this.http.get(`cameras`) } - camera(name: string) { - return this.http.get(`cameras/${name}`) + camera(id: string) { + return this.http.get(`cameras/${id}`) } cameraConnect(camera: Camera) { - return this.http.put(`cameras/${camera.name}/connect`) + return this.http.put(`cameras/${camera.id}/connect`) } cameraDisconnect(camera: Camera) { - return this.http.put(`cameras/${camera.name}/disconnect`) + return this.http.put(`cameras/${camera.id}/disconnect`) } cameraIsCapturing(camera: Camera) { - return this.http.get(`cameras/${camera.name}/capturing`) + return this.http.get(`cameras/${camera.id}/capturing`) } // TODO: Rotator - cameraSnoop(camera: Camera, mount?: Mount, wheel?: FilterWheel, focuser?: Focuser) { - const query = this.http.query({ mount: mount?.name, wheel: wheel?.name, focuser: focuser?.name }) - return this.http.put(`cameras/${camera.name}/snoop?${query}`) + cameraSnoop(camera: Camera, equipment: Equipment) { + const { mount, wheel, focuser, rotator } = equipment + const query = this.http.query({ mount: mount?.name, wheel: wheel?.name, focuser: focuser?.name, rotator: rotator?.name }) + return this.http.put(`cameras/${camera.id}/snoop?${query}`) } cameraCooler(camera: Camera, enabled: boolean) { - return this.http.put(`cameras/${camera.name}/cooler?enabled=${enabled}`) + return this.http.put(`cameras/${camera.id}/cooler?enabled=${enabled}`) } cameraSetpointTemperature(camera: Camera, temperature: number) { - return this.http.put(`cameras/${camera.name}/temperature/setpoint?temperature=${temperature}`) + return this.http.put(`cameras/${camera.id}/temperature/setpoint?temperature=${temperature}`) } cameraStartCapture(camera: Camera, data: CameraStartCapture) { - return this.http.put(`cameras/${camera.name}/capture/start`, data) + return this.http.put(`cameras/${camera.id}/capture/start`, data) } cameraAbortCapture(camera: Camera) { - return this.http.put(`cameras/${camera.name}/capture/abort`) + return this.http.put(`cameras/${camera.id}/capture/abort`) } // MOUNT @@ -95,79 +97,93 @@ export class ApiService { return this.http.get(`mounts`) } - mount(name: string) { - return this.http.get(`mounts/${name}`) + mount(id: string) { + return this.http.get(`mounts/${id}`) } mountConnect(mount: Mount) { - return this.http.put(`mounts/${mount.name}/connect`) + return this.http.put(`mounts/${mount.id}/connect`) } mountDisconnect(mount: Mount) { - return this.http.put(`mounts/${mount.name}/disconnect`) + return this.http.put(`mounts/${mount.id}/disconnect`) } mountTracking(mount: Mount, enabled: boolean) { - return this.http.put(`mounts/${mount.name}/tracking?enabled=${enabled}`) + return this.http.put(`mounts/${mount.id}/tracking?enabled=${enabled}`) } mountSync(mount: Mount, rightAscension: Angle, declination: Angle, j2000: boolean) { const query = this.http.query({ rightAscension, declination, j2000 }) - return this.http.put(`mounts/${mount.name}/sync?${query}`) + return this.http.put(`mounts/${mount.id}/sync?${query}`) } mountSlew(mount: Mount, rightAscension: Angle, declination: Angle, j2000: boolean) { const query = this.http.query({ rightAscension, declination, j2000 }) - return this.http.put(`mounts/${mount.name}/slew?${query}`) + return this.http.put(`mounts/${mount.id}/slew?${query}`) } mountGoTo(mount: Mount, rightAscension: Angle, declination: Angle, j2000: boolean) { const query = this.http.query({ rightAscension, declination, j2000 }) - return this.http.put(`mounts/${mount.name}/goto?${query}`) + return this.http.put(`mounts/${mount.id}/goto?${query}`) } mountPark(mount: Mount) { - return this.http.put(`mounts/${mount.name}/park`) + return this.http.put(`mounts/${mount.id}/park`) } mountUnpark(mount: Mount) { - return this.http.put(`mounts/${mount.name}/unpark`) + return this.http.put(`mounts/${mount.id}/unpark`) } mountHome(mount: Mount) { - return this.http.put(`mounts/${mount.name}/home`) + return this.http.put(`mounts/${mount.id}/home`) } mountAbort(mount: Mount) { - return this.http.put(`mounts/${mount.name}/abort`) + return this.http.put(`mounts/${mount.id}/abort`) } mountTrackMode(mount: Mount, mode: TrackMode) { - return this.http.put(`mounts/${mount.name}/track-mode?mode=${mode}`) + return this.http.put(`mounts/${mount.id}/track-mode?mode=${mode}`) } mountSlewRate(mount: Mount, rate: SlewRate) { - return this.http.put(`mounts/${mount.name}/slew-rate?rate=${rate.name}`) + return this.http.put(`mounts/${mount.id}/slew-rate?rate=${rate.name}`) } mountMove(mount: Mount, direction: GuideDirection, enabled: boolean) { - return this.http.put(`mounts/${mount.name}/move?direction=${direction}&enabled=${enabled}`) + return this.http.put(`mounts/${mount.id}/move?direction=${direction}&enabled=${enabled}`) } mountComputeLocation(mount: Mount, j2000: boolean, rightAscension: Angle, declination: Angle, equatorial: boolean = true, horizontal: boolean = true, meridianAt: boolean = false, ) { const query = this.http.query({ rightAscension, declination, j2000, equatorial, horizontal, meridianAt }) - return this.http.get(`mounts/${mount.name}/location?${query}`) + return this.http.get(`mounts/${mount.id}/location?${query}`) } mountCelestialLocation(mount: Mount, type: CelestialLocationType) { - return this.http.get(`mounts/${mount.name}/location/${type}`) + return this.http.get(`mounts/${mount.id}/location/${type}`) } pointMountHere(mount: Mount, path: string, x: number, y: number) { const query = this.http.query({ path, x, y }) - return this.http.put(`mounts/${mount.name}/point-here?${query}`) + return this.http.put(`mounts/${mount.id}/point-here?${query}`) + } + + mountRemoteControlStart(mount: Mount, type: MountRemoteControlType, host: string, port: number) { + const query = this.http.query({ type, host, port }) + return this.http.put(`mounts/${mount.id}/remote-control/start?${query}`) + } + + mountRemoteControlList(mount: Mount) { + return this.http.get(`mounts/${mount.id}/remote-control`) + } + + mountRemoteControlStop(mount: Mount, type: MountRemoteControlType) { + const query = this.http.query({ type }) + return this.http.put(`mounts/${mount.id}/remote-control/stop?${query}`) } // FOCUSER @@ -176,36 +192,36 @@ export class ApiService { return this.http.get(`focusers`) } - focuser(name: string) { - return this.http.get(`focusers/${name}`) + focuser(id: string) { + return this.http.get(`focusers/${id}`) } focuserConnect(focuser: Focuser) { - return this.http.put(`focusers/${focuser.name}/connect`) + return this.http.put(`focusers/${focuser.id}/connect`) } focuserDisconnect(focuser: Focuser) { - return this.http.put(`focusers/${focuser.name}/disconnect`) + return this.http.put(`focusers/${focuser.id}/disconnect`) } focuserMoveIn(focuser: Focuser, steps: number) { - return this.http.put(`focusers/${focuser.name}/move-in?steps=${steps}`) + return this.http.put(`focusers/${focuser.id}/move-in?steps=${steps}`) } focuserMoveOut(focuser: Focuser, steps: number) { - return this.http.put(`focusers/${focuser.name}/move-out?steps=${steps}`) + return this.http.put(`focusers/${focuser.id}/move-out?steps=${steps}`) } focuserMoveTo(focuser: Focuser, steps: number) { - return this.http.put(`focusers/${focuser.name}/move-to?steps=${steps}`) + return this.http.put(`focusers/${focuser.id}/move-to?steps=${steps}`) } focuserAbort(focuser: Focuser) { - return this.http.put(`focusers/${focuser.name}/abort`) + return this.http.put(`focusers/${focuser.id}/abort`) } focuserSync(focuser: Focuser, steps: number) { - return this.http.put(`focusers/${focuser.name}/sync?steps=${steps}`) + return this.http.put(`focusers/${focuser.id}/sync?steps=${steps}`) } // FILTER WHEEL @@ -214,24 +230,62 @@ export class ApiService { return this.http.get(`wheels`) } - wheel(name: string) { - return this.http.get(`wheels/${name}`) + wheel(id: string) { + return this.http.get(`wheels/${id}`) } wheelConnect(wheel: FilterWheel) { - return this.http.put(`wheels/${wheel.name}/connect`) + return this.http.put(`wheels/${wheel.id}/connect`) } wheelDisconnect(wheel: FilterWheel) { - return this.http.put(`wheels/${wheel.name}/disconnect`) + return this.http.put(`wheels/${wheel.id}/disconnect`) } wheelMoveTo(wheel: FilterWheel, position: number) { - return this.http.put(`wheels/${wheel.name}/move-to?position=${position}`) + return this.http.put(`wheels/${wheel.id}/move-to?position=${position}`) } wheelSync(wheel: FilterWheel, names: string[]) { - return this.http.put(`wheels/${wheel.name}/sync?names=${names.join(',')}`) + return this.http.put(`wheels/${wheel.id}/sync?names=${names.join(',')}`) + } + + // ROTATOR + + rotators() { + return this.http.get(`rotators`) + } + + rotator(id: string) { + return this.http.get(`rotators/${id}`) + } + + rotatorConnect(rotator: Rotator) { + return this.http.put(`rotators/${rotator.id}/connect`) + } + + rotatorDisconnect(rotator: Rotator) { + return this.http.put(`rotators/${rotator.id}/disconnect`) + } + + focuserReverse(rotator: Rotator, enabled: boolean) { + return this.http.put(`rotators/${rotator.id}/reverse?enabled=${enabled}`) + } + + rotatorMove(rotator: Rotator, angle: number) { + return this.http.put(`rotators/${rotator.id}/move?angle=${angle}`) + } + + rotatorAbort(rotator: Rotator) { + return this.http.put(`rotators/${rotator.id}/abort`) + } + + rotatorHome(rotator: Rotator) { + return this.http.put(`rotators/${rotator.id}/home`) + } + + rotatorSync(rotator: Rotator, angle: number) { + return this.http.put(`rotators/${rotator.id}/sync?angle=${angle}`) } // GUIDE OUTPUT @@ -240,21 +294,21 @@ export class ApiService { return this.http.get(`guide-outputs`) } - guideOutput(name: string) { - return this.http.get(`guide-outputs/${name}`) + guideOutput(id: string) { + return this.http.get(`guide-outputs/${id}`) } guideOutputConnect(guideOutput: GuideOutput) { - return this.http.put(`guide-outputs/${guideOutput.name}/connect`) + return this.http.put(`guide-outputs/${guideOutput.id}/connect`) } guideOutputDisconnect(guideOutput: GuideOutput) { - return this.http.put(`guide-outputs/${guideOutput.name}/disconnect`) + return this.http.put(`guide-outputs/${guideOutput.id}/disconnect`) } guideOutputPulse(guideOutput: GuideOutput, direction: GuideDirection, duration: number) { const query = this.http.query({ direction, duration }) - return this.http.put(`guide-outputs/${guideOutput.name}/pulse?${query}`) + return this.http.put(`guide-outputs/${guideOutput.id}/pulse?${query}`) } // GUIDING @@ -314,29 +368,10 @@ export class ApiService { // IMAGE - async openImage( - path: string, - camera?: Camera, - calibrate: boolean = false, - debayer: boolean = false, - force: boolean = false, - autoStretch: boolean = true, - shadow: number = 0, - highlight: number = 1, - midtone: number = 0.5, - mirrorHorizontal: boolean = false, - mirrorVertical: boolean = false, - invert: boolean = false, - scnrEnabled: boolean = false, - scnrChannel: ImageChannel = 'GREEN', - scnrAmount: number = 0.5, - scnrProtectionMode: SCNRProtectionMethod = 'AVERAGE_NEUTRAL', - ) { - const query = this.http.query({ path, camera: camera?.name, calibrate, force, debayer, autoStretch, shadow, highlight, midtone, mirrorHorizontal, mirrorVertical, invert, scnrEnabled, scnrChannel, scnrAmount, scnrProtectionMode }) - const response = await this.http.getBlob(`image?${query}`) - + async openImage(path: string, transformation: ImageTransformation, camera?: Camera) { + const query = this.http.query({ path, camera: camera?.name }) + const response = await this.http.postBlob(`image?${query}`, transformation) const info = JSON.parse(response.headers.get('X-Image-Info')!) as ImageInfo - return { info, blob: response.body! } } @@ -348,23 +383,23 @@ export class ApiService { // INDI indiProperties(device: Device) { - return this.http.get[]>(`indi/${device.name}/properties`) + return this.http.get[]>(`indi/${device.id}/properties`) } indiSendProperty(device: Device, property: INDISendProperty) { - return this.http.put(`indi/${device.name}/send`, property) + return this.http.put(`indi/${device.id}/send`, property) } indiStartListening(device: Device) { - return this.http.put(`indi/listener/${device.name}/start`) + return this.http.put(`indi/listener/${device.id}/start`) } indiStopListening(device: Device) { - return this.http.put(`indi/listener/${device.name}/stop`) + return this.http.put(`indi/listener/${device.id}/stop`) } indiLog(device: Device) { - return this.http.get(`indi/${device.name}/log`) + return this.http.get(`indi/${device.id}/log`) } // SKY ATLAS @@ -468,15 +503,15 @@ export class ApiService { annotationsOfImage( path: string, starsAndDSOs: boolean = true, minorPlanets: boolean = false, - minorPlanetMagLimit: number = 12.0, + minorPlanetMagLimit: number = 12.0, useSimbad: boolean = false, ) { - const query = this.http.query({ path, starsAndDSOs, minorPlanets, minorPlanetMagLimit, hasLocation: true }) + const query = this.http.query({ path, starsAndDSOs, minorPlanets, minorPlanetMagLimit, useSimbad, hasLocation: true }) return this.http.get(`image/annotations?${query}`) } - saveImageAs(inputPath: string, outputPath: string) { - const query = this.http.query({ inputPath, outputPath }) - return this.http.put(`image/save-as?${query}`) + saveImageAs(path: string, save: ImageSaveDialog, camera?: Camera) { + const query = this.http.query({ path, camera: camera?.name }) + return this.http.put(`image/save-as?${query}`, save) } coordinateInterpolation(path: string) { @@ -504,17 +539,21 @@ export class ApiService { // CALIBRATION - calibrationFrames(camera: Camera) { - return this.http.get(`calibration-frames/${camera.name}`) + calibrationGroups() { + return this.http.get('calibration-frames') + } + + calibrationFrames(name: string) { + return this.http.get(`calibration-frames/${name}`) } - uploadCalibrationFrame(camera: Camera, path: string) { + uploadCalibrationFrame(name: string, path: string) { const query = this.http.query({ path }) - return this.http.put(`calibration-frames/${camera.name}?${query}`) + return this.http.put(`calibration-frames/${name}?${query}`) } editCalibrationFrame(frame: CalibrationFrame) { - const query = this.http.query({ path: frame.path, enabled: frame.enabled }) + const query = this.http.query({ name: frame.name, enabled: frame.enabled }) return this.http.patch(`calibration-frames/${frame.id}?${query}`) } @@ -539,59 +578,60 @@ export class ApiService { // DARV darvStart(camera: Camera, guideOutput: GuideOutput, data: DARVStart) { - return this.http.put(`polar-alignment/darv/${camera.name}/${guideOutput.name}/start`, data) + return this.http.put(`polar-alignment/darv/${camera.id}/${guideOutput.id}/start`, data) } - darvStop(id: string) { - return this.http.put(`polar-alignment/darv/${id}/stop`) + darvStop(camera: Camera) { + return this.http.put(`polar-alignment/darv/${camera.id}/stop`) } // TPPA tppaStart(camera: Camera, mount: Mount, data: TPPAStart) { - return this.http.put(`polar-alignment/tppa/${camera.name}/${mount.name}/start`, data) + return this.http.put(`polar-alignment/tppa/${camera.id}/${mount.id}/start`, data) } - tppaStop(id: string) { - return this.http.put(`polar-alignment/tppa/${id}/stop`) + tppaStop(camera: Camera) { + return this.http.put(`polar-alignment/tppa/${camera.id}/stop`) } - tppaPause(id: string) { - return this.http.put(`polar-alignment/tppa/${id}/pause`) + tppaPause(camera: Camera) { + return this.http.put(`polar-alignment/tppa/${camera.id}/pause`) } - tppaUnpause(id: string) { - return this.http.put(`polar-alignment/tppa/${id}/unpause`) + tppaUnpause(camera: Camera) { + return this.http.put(`polar-alignment/tppa/${camera.id}/unpause`) } // SEQUENCER sequencerStart(camera: Camera, plan: SequencePlan) { - const body: SequencePlan = { ...plan, camera: undefined, wheel: undefined, focuser: undefined } - return this.http.put(`sequencer/${camera.name}/start`, body) + const body: SequencePlan = { ...plan, mount: undefined, camera: undefined, wheel: undefined, focuser: undefined } + const query = this.http.query({ mount: plan.mount?.name, focuser: plan.focuser?.name, wheel: plan.wheel?.name }) + return this.http.put(`sequencer/${camera.id}/start?${query}`, body) } sequencerStop(camera: Camera) { - return this.http.put(`sequencer/${camera.name}/stop`) + return this.http.put(`sequencer/${camera.id}/stop`) } // FLAT WIZARD flatWizardStart(camera: Camera, request: FlatWizardRequest) { - return this.http.put(`flat-wizard/${camera.name}/start`, request) + return this.http.put(`flat-wizard/${camera.id}/start`, request) } flatWizardStop(camera: Camera) { - return this.http.put(`flat-wizard/${camera.name}/stop`) + return this.http.put(`flat-wizard/${camera.id}/stop`) } // SOLVER solveImage( - options: PlateSolverOptions, path: string, blind: boolean, + solver: PlateSolverPreference, path: string, blind: boolean, centerRA: Angle, centerDEC: Angle, radius: Angle, ) { - const query = this.http.query({ ...options, path, blind, centerRA, centerDEC, radius }) + const query = this.http.query({ ...solver, path, blind, centerRA, centerDEC, radius }) return this.http.put(`plate-solver?${query}`) } diff --git a/desktop/src/shared/services/browser-window.service.ts b/desktop/src/shared/services/browser-window.service.ts index ddf7ea10d..f9398cdcf 100644 --- a/desktop/src/shared/services/browser-window.service.ts +++ b/desktop/src/shared/services/browser-window.service.ts @@ -8,6 +8,7 @@ import { Device } from '../types/device.types' import { Focuser } from '../types/focuser.types' import { ImageData, ImageSource } from '../types/image.types' import { Mount } from '../types/mount.types' +import { Rotator } from '../types/rotator.types' import { FilterWheel, WheelDialogInput } from '../types/wheel.types' import { ElectronService } from './electron.service' @@ -30,7 +31,7 @@ export class BrowserWindowService { } openCamera(options: OpenWindowOptionsWithData) { - Object.assign(options, { icon: 'camera', width: 400, height: 479 }) + Object.assign(options, { icon: 'camera', width: 400, height: 467 }) return this.openWindow({ ...options, id: `camera.${options.data.name}`, path: 'camera' }) } @@ -45,10 +46,15 @@ export class BrowserWindowService { } openWheel(options: OpenWindowOptionsWithData) { - Object.assign(options, { icon: 'filter-wheel', width: 285, height: 195 }) + Object.assign(options, { icon: 'filter-wheel', width: 280, height: 195 }) this.openWindow({ ...options, id: `wheel.${options.data.name}`, path: 'wheel' }) } + openRotator(options: OpenWindowOptionsWithData) { + Object.assign(options, { icon: 'rotate', width: 280, height: 210 }) + this.openWindow({ ...options, id: `rotator.${options.data.name}`, path: 'rotator' }) + } + openWheelDialog(options: OpenWindowOptionsWithData) { Object.assign(options, { icon: 'filter-wheel', width: 300, height: 217 }) return this.openModal({ ...options, id: `wheel.${options.data.wheel.name}.modal`, path: 'wheel' }) @@ -59,17 +65,17 @@ export class BrowserWindowService { this.openWindow({ ...options, id: 'guider', path: 'guider', data: undefined }) } - async openCameraImage(camera: Camera, source: ImageSource = 'CAMERA') { + async openCameraImage(camera: Camera, source: ImageSource = 'CAMERA', capture?: CameraStartCapture) { const factor = camera.height / camera.width const id = `image.${camera.name}` - await this.openWindow({ id, path: 'image', icon: 'image', width: '50%', height: `${factor}w`, resizable: true, data: { camera, source } }) + await this.openWindow({ id, path: 'image', icon: 'image', width: '50%', height: `${factor}w`, resizable: true, data: { camera, source, capture } }) return id } async openImage(data: Omit & { id?: string, path: string }) { const hash = data.id || uuidv4() const id = `image.${hash}` - await this.openWindow({ id, path: 'image', icon: 'image', width: '50%', height: `0.9w`, resizable: true, data }) + await this.openWindow({ id, path: 'image', icon: 'image', width: '50%', height: `0.9w`, resizable: true, data, autoResizable: false }) return id } @@ -89,7 +95,7 @@ export class BrowserWindowService { } openAlignment(options: OpenWindowOptions = {}) { - Object.assign(options, { icon: 'star', width: 450, height: 344 }) + Object.assign(options, { icon: 'star', width: 415, height: 365 }) this.openWindow({ ...options, id: 'alignment', path: 'alignment', data: undefined }) } @@ -99,13 +105,13 @@ export class BrowserWindowService { } openFlatWizard(options: OpenWindowOptions = {}) { - Object.assign(options, { icon: 'star', width: 410, height: 331 }) + Object.assign(options, { icon: 'star', width: 385, height: 370 }) this.openWindow({ ...options, id: 'flat-wizard', path: 'flat-wizard', data: undefined }) } openSettings(options: OpenWindowOptions = {}) { - Object.assign(options, { icon: 'settings', width: 580, height: 451 }) - this.openWindow({ ...options, id: 'settings', path: 'settings', data: undefined, resizable: true }) + Object.assign(options, { icon: 'settings', width: 490, height: 460 }) + this.openWindow({ ...options, id: 'settings', path: 'settings', data: undefined, resizable: true, minWidth: 490, minHeight: 460, autoResizable: false }) } openCalculator(options: OpenWindowOptions = {}) { @@ -113,9 +119,9 @@ export class BrowserWindowService { this.openWindow({ ...options, id: 'calculator', path: 'calculator', data: undefined }) } - openCalibration(options: OpenWindowOptionsWithData) { - Object.assign(options, { icon: 'stack', width: 510, height: 508 }) - this.openWindow({ ...options, id: 'calibration', path: 'calibration' }) + openCalibration(options: OpenWindowOptions = {}) { + Object.assign(options, { icon: 'stack', width: 420, height: 400, minHeight: 400 }) + this.openWindow({ ...options, id: 'calibration', path: 'calibration', data: undefined }) } openAbout() { diff --git a/desktop/src/shared/services/electron.service.ts b/desktop/src/shared/services/electron.service.ts index 44b79941e..58a2c58d2 100644 --- a/desktop/src/shared/services/electron.service.ts +++ b/desktop/src/shared/services/electron.service.ts @@ -7,18 +7,19 @@ import { Injectable } from '@angular/core' import * as childProcess from 'child_process' import { ipcRenderer, webFrame } from 'electron' import * as fs from 'fs' -import { DARVElapsed, TPPAElapsed } from '../types/alignment.types' +import { DARVEvent, TPPAEvent } from '../types/alignment.types' import { ApiEventType, DeviceMessageEvent } from '../types/api.types' import { CloseWindow, InternalEventType, JsonFile, OpenDirectory, OpenFile, SaveJson } from '../types/app.types' import { Location, SkyAtlasUpdated } from '../types/atlas.types' -import { Camera, CameraCaptureElapsed } from '../types/camera.types' +import { Camera, CameraCaptureEvent } from '../types/camera.types' import { INDIMessageEvent } from '../types/device.types' -import { FlatWizardElapsed } from '../types/flat-wizard.types' +import { FlatWizardEvent } from '../types/flat-wizard.types' import { Focuser } from '../types/focuser.types' import { GuideOutput, Guider, GuiderHistoryStep, GuiderMessageEvent } from '../types/guider.types' import { ConnectionClosed } from '../types/home.types' import { Mount } from '../types/mount.types' -import { SequencerElapsed } from '../types/sequencer.types' +import { Rotator } from '../types/rotator.types' +import { SequencerEvent } from '../types/sequencer.types' import { FilterWheel } from '../types/wheel.types' import { ApiService } from './api.service' @@ -29,13 +30,16 @@ type EventMappedType = { 'CAMERA.UPDATED': DeviceMessageEvent 'CAMERA.ATTACHED': DeviceMessageEvent 'CAMERA.DETACHED': DeviceMessageEvent - 'CAMERA.CAPTURE_ELAPSED': CameraCaptureElapsed + 'CAMERA.CAPTURE_ELAPSED': CameraCaptureEvent 'MOUNT.UPDATED': DeviceMessageEvent 'MOUNT.ATTACHED': DeviceMessageEvent 'MOUNT.DETACHED': DeviceMessageEvent 'FOCUSER.UPDATED': DeviceMessageEvent 'FOCUSER.ATTACHED': DeviceMessageEvent 'FOCUSER.DETACHED': DeviceMessageEvent + 'ROTATOR.UPDATED': DeviceMessageEvent + 'ROTATOR.ATTACHED': DeviceMessageEvent + 'ROTATOR.DETACHED': DeviceMessageEvent 'WHEEL.UPDATED': DeviceMessageEvent 'WHEEL.ATTACHED': DeviceMessageEvent 'WHEEL.DETACHED': DeviceMessageEvent @@ -47,14 +51,15 @@ type EventMappedType = { 'GUIDER.UPDATED': GuiderMessageEvent 'GUIDER.STEPPED': GuiderMessageEvent 'GUIDER.MESSAGE_RECEIVED': GuiderMessageEvent - 'DARV.ELAPSED': DARVElapsed - 'TPPA.ELAPSED': TPPAElapsed + 'DARV.ELAPSED': DARVEvent + 'TPPA.ELAPSED': TPPAEvent 'DATA.CHANGED': any 'LOCATION.CHANGED': Location - 'SEQUENCER.ELAPSED': SequencerElapsed - 'FLAT_WIZARD.ELAPSED': FlatWizardElapsed + 'SEQUENCER.ELAPSED': SequencerEvent + 'FLAT_WIZARD.ELAPSED': FlatWizardEvent 'CONNECTION.CLOSED': ConnectionClosed 'SKY_ATLAS.PROGRESS_CHANGED': SkyAtlasUpdated + 'CALIBRATION.CHANGED': unknown } @Injectable({ providedIn: 'root' }) @@ -110,7 +115,7 @@ export class ElectronService { return this.send('FILE.SAVE', data) } - openFits(data?: OpenFile): Promise { + openImage(data?: OpenFile): Promise { return this.openFile({ ...data, filters: [ { name: 'All', extensions: ['fits', 'fit', 'xisf'] }, @@ -120,14 +125,14 @@ export class ElectronService { }) } - saveFits(data?: OpenFile) { + saveImage(data?: OpenFile) { return this.saveFile({ ...data, filters: [ - { name: 'All', extensions: ['fits', 'fit', 'xisf', 'png', 'jpe?g'] }, + { name: 'All', extensions: ['fits', 'fit', 'xisf', 'png', 'jpg', 'jpeg'] }, { name: 'FITS', extensions: ['fits', 'fit'] }, { name: 'XISF', extensions: ['xisf'] }, - { name: 'Image', extensions: ['png', 'jpe?g'] }, + { name: 'Image', extensions: ['png', 'jpg', 'jpeg'] }, ] }) } @@ -198,7 +203,15 @@ export class ElectronService { this.send('WINDOW.MAXIMIZE') } + fullscreenWindow(enabled?: boolean): Promise { + return this.send('WINDOW.FULLSCREEN', enabled) + } + closeWindow(data: CloseWindow) { return this.send('WINDOW.CLOSE', data) } + + calibrationChanged() { + this.send('CALIBRATION.CHANGED') + } } diff --git a/desktop/src/shared/services/http.service.ts b/desktop/src/shared/services/http.service.ts index 7f3296722..3ebe02531 100644 --- a/desktop/src/shared/services/http.service.ts +++ b/desktop/src/shared/services/http.service.ts @@ -10,7 +10,7 @@ export class HttpService { constructor(private http: HttpClient) { } get baseUrl() { - return `http://localhost:${window.apiPort}` + return `http://${window.apiHost}:${window.apiPort}` } get(path: string) { @@ -30,6 +30,10 @@ export class HttpService { return firstValueFrom(this.http.post(`${this.baseUrl}/${path}?${query}`, null)) } + postBlob(path: string, body?: any) { + return firstValueFrom(this.http.post(`${this.baseUrl}/${path}`, body, { observe: 'response', responseType: 'blob' })) + } + patch(path: string, body?: any) { return firstValueFrom(this.http.patch(`${this.baseUrl}/${path}`, body)) } @@ -43,6 +47,10 @@ export class HttpService { return firstValueFrom(this.http.put(`${this.baseUrl}/${path}?${query}`, null)) } + putBlob(path: string, body?: any) { + return firstValueFrom(this.http.put(`${this.baseUrl}/${path}`, body, { observe: 'response', responseType: 'blob' })) + } + delete(path: string) { return firstValueFrom(this.http.delete(`${this.baseUrl}/${path}`)) } diff --git a/desktop/src/shared/services/preference.service.ts b/desktop/src/shared/services/preference.service.ts index 72606f75f..98b902672 100644 --- a/desktop/src/shared/services/preference.service.ts +++ b/desktop/src/shared/services/preference.service.ts @@ -2,12 +2,14 @@ import { Injectable } from '@angular/core' import { SkyAtlasPreference } from '../../app/atlas/atlas.component' import { AlignmentPreference, EMPTY_ALIGNMENT_PREFERENCE } from '../types/alignment.types' import { EMPTY_LOCATION, Location } from '../types/atlas.types' +import { CalibrationPreference } from '../types/calibration.types' import { Camera, CameraPreference, CameraStartCapture, EMPTY_CAMERA_PREFERENCE } from '../types/camera.types' import { Device } from '../types/device.types' -import { Focuser } from '../types/focuser.types' -import { ConnectionDetails, Equipment } from '../types/home.types' +import { Focuser, FocuserPreference } from '../types/focuser.types' +import { ConnectionDetails, Equipment, HomePreference } from '../types/home.types' import { EMPTY_IMAGE_PREFERENCE, FOV, ImagePreference } from '../types/image.types' -import { EMPTY_PLATE_SOLVER_OPTIONS, PlateSolverOptions, PlateSolverType } from '../types/settings.types' +import { Rotator, RotatorPreference } from '../types/rotator.types' +import { EMPTY_PLATE_SOLVER_PREFERENCE, PlateSolverPreference, PlateSolverType } from '../types/settings.types' import { FilterWheel, WheelPreference } from '../types/wheel.types' import { LocalStorageService } from './local-storage.service' @@ -61,8 +63,8 @@ export class PreferenceService { return new PreferenceData(this.storage, `camera.${camera.name}.tppa`, () => this.cameraPreference(camera).get()) } - plateSolverOptions(type: PlateSolverType) { - return new PreferenceData(this.storage, `settings.plateSolver.${type}`, () => { ...EMPTY_PLATE_SOLVER_OPTIONS, type }) + plateSolverPreference(type: PlateSolverType) { + return new PreferenceData(this.storage, `plateSolver.${type}`, () => { ...EMPTY_PLATE_SOLVER_PREFERENCE, type }) } equipmentForDevice(device: Device) { @@ -73,12 +75,21 @@ export class PreferenceService { return new PreferenceData(this.storage, `focusOffset.${wheel.name}.${position}.${focuser.name}`, () => 0) } + focuserPreference(focuser: Focuser) { + return new PreferenceData(this.storage, `focuser.${focuser.name}`, {}) + } + + rotatorPreference(rotator: Rotator) { + return new PreferenceData(this.storage, `rotator.${rotator.name}`, {}) + } + + readonly connections = new PreferenceData(this.storage, 'home.connections', () => []) readonly locations = new PreferenceData(this.storage, 'locations', () => [structuredClone(EMPTY_LOCATION)]) readonly selectedLocation = new PreferenceData(this.storage, 'locations.selected', () => structuredClone(EMPTY_LOCATION)) + readonly homePreference = new PreferenceData(this.storage, 'home', () => {}) readonly imagePreference = new PreferenceData(this.storage, 'image', () => structuredClone(EMPTY_IMAGE_PREFERENCE)) readonly skyAtlasPreference = new PreferenceData(this.storage, 'atlas', () => {}) readonly alignmentPreference = new PreferenceData(this.storage, 'alignment', () => structuredClone(EMPTY_ALIGNMENT_PREFERENCE)) - readonly connections = new PreferenceData(this.storage, 'home.connections', () => []) - readonly homeImageDirectory = new PreferenceData(this.storage, 'home.image.directory', '') readonly imageFOVs = new PreferenceData(this.storage, 'image.fovs', () => []) + readonly calibrationPreference = new PreferenceData(this.storage, 'calibration', () => {}) } \ No newline at end of file diff --git a/desktop/src/shared/types/alignment.types.ts b/desktop/src/shared/types/alignment.types.ts index 90c9333c9..3b48be378 100644 --- a/desktop/src/shared/types/alignment.types.ts +++ b/desktop/src/shared/types/alignment.types.ts @@ -1,13 +1,13 @@ import { Angle } from './atlas.types' -import { CameraStartCapture } from './camera.types' +import { Camera, CameraCaptureEvent, CameraStartCapture } from './camera.types' import { GuideDirection } from './guider.types' -import { PlateSolverOptions, PlateSolverType } from './settings.types' +import { PlateSolverPreference, PlateSolverType } from './settings.types' export type Hemisphere = 'NORTHERN' | 'SOUTHERN' export type DARVState = 'IDLE' | 'INITIAL_PAUSE' | 'FORWARD' | 'BACKWARD' -export type TPPAState = 'IDLE' | 'SLEWING' | 'SOLVING' | 'SOLVED' | 'PAUSING' | 'PAUSED' | 'COMPUTED' | 'FAILED' | 'FINISHED' +export type TPPAState = 'IDLE' | 'SLEWING' | 'SLEWED' | 'SETTLING' | 'EXPOSURING' | 'SOLVING' | 'SOLVED' | 'COMPUTED' | 'PAUSING' | 'PAUSED' | 'FINISHED' | 'FAILED' export type AlignmentMethod = 'DARV' | 'TPPA' @@ -16,10 +16,10 @@ export interface AlignmentPreference { darvExposureTime: number darvHemisphere: Hemisphere tppaStartFromCurrentPosition: boolean - tppaEastDirection: boolean + tppaStepDirection: GuideDirection tppaCompensateRefraction: boolean tppaStopTrackingWhenDone: boolean - tppaStepDistance: number + tppaStepDuration: number tppaPlateSolverType: PlateSolverType } @@ -28,10 +28,10 @@ export const EMPTY_ALIGNMENT_PREFERENCE: AlignmentPreference = { darvExposureTime: 30, darvHemisphere: 'NORTHERN', tppaStartFromCurrentPosition: true, - tppaEastDirection: true, + tppaStepDirection: 'EAST', tppaCompensateRefraction: true, tppaStopTrackingWhenDone: true, - tppaStepDistance: 10, + tppaStepDuration: 5, tppaPlateSolverType: 'ASTAP', } @@ -43,28 +43,26 @@ export interface DARVStart { reversed: boolean } -export interface DARVElapsed extends MessageEvent { - id: string - remainingTime: number - progress: number +export interface DARVEvent extends MessageEvent { + camera: Camera state: DARVState direction?: GuideDirection + capture: CameraCaptureEvent } export interface TPPAStart { capture: CameraStartCapture - plateSolver: PlateSolverOptions + plateSolver: PlateSolverPreference startFromCurrentPosition: boolean - eastDirection: boolean compensateRefraction: boolean stopTrackingWhenDone: boolean - stepDistance: number + stepDirection: GuideDirection + stepDuration: number + stepSpeed?: string } -export interface TPPAElapsed extends MessageEvent { - id: string - elapsedTime: number - stepCount: number +export interface TPPAEvent extends MessageEvent { + camera: Camera state: TPPAState rightAscension: Angle declination: Angle @@ -73,4 +71,5 @@ export interface TPPAElapsed extends MessageEvent { totalError: Angle azimuthErrorDirection: string altitudeErrorDirection: string + capture?: CameraCaptureEvent } diff --git a/desktop/src/shared/types/api.types.ts b/desktop/src/shared/types/api.types.ts index f64c81ec0..65733134a 100644 --- a/desktop/src/shared/types/api.types.ts +++ b/desktop/src/shared/types/api.types.ts @@ -21,6 +21,8 @@ export const API_EVENT_TYPES = [ 'FOCUSER.UPDATED', 'FOCUSER.ATTACHED', 'FOCUSER.DETACHED', // Filter Wheel. 'WHEEL.UPDATED', 'WHEEL.ATTACHED', 'WHEEL.DETACHED', + // Rotator. + 'ROTATOR.UPDATED', 'ROTATOR.ATTACHED', 'ROTATOR.DETACHED', // Guide Output. 'GUIDE_OUTPUT.ATTACHED', 'GUIDE_OUTPUT.DETACHED', 'GUIDE_OUTPUT.UPDATED', // Guider. diff --git a/desktop/src/shared/types/app.types.ts b/desktop/src/shared/types/app.types.ts index 59dff8d69..2c1ebb5f5 100644 --- a/desktop/src/shared/types/app.types.ts +++ b/desktop/src/shared/types/app.types.ts @@ -10,7 +10,7 @@ export interface ToggleableMenuItem extends MenuItem { toggleable: boolean toggled: boolean - toggle(event: CheckboxChangeEvent): void + toggle: (event: CheckboxChangeEvent) => void } export interface NotificationEvent extends MessageEvent { @@ -23,7 +23,8 @@ export interface NotificationEvent extends MessageEvent { export const INTERNAL_EVENT_TYPES = [ 'DIRECTORY.OPEN', 'FILE.OPEN', 'FILE.SAVE', 'WINDOW.OPEN', 'WINDOW.CLOSE', 'WINDOW.PIN', 'WINDOW.UNPIN', 'WINDOW.MINIMIZE', 'WINDOW.MAXIMIZE', 'WINDOW.RESIZE', - 'WHEEL.RENAMED', 'LOCATION.CHANGED', 'JSON.WRITE', 'JSON.READ' + 'WHEEL.RENAMED', 'LOCATION.CHANGED', 'JSON.WRITE', 'JSON.READ', + 'CALIBRATION.CHANGED', 'WINDOW.FULLSCREEN' ] as const export type InternalEventType = (typeof INTERNAL_EVENT_TYPES)[number] @@ -35,6 +36,8 @@ export interface OpenWindowOptions { height?: number | string bringToFront?: boolean requestFocus?: boolean + minWidth?: number + minHeight?: number } export interface OpenWindowOptionsWithData extends OpenWindowOptions { diff --git a/desktop/src/shared/types/calibration.types.ts b/desktop/src/shared/types/calibration.types.ts index 4f5336598..52ad91d64 100644 --- a/desktop/src/shared/types/calibration.types.ts +++ b/desktop/src/shared/types/calibration.types.ts @@ -3,7 +3,7 @@ import { FrameType } from './camera.types' export interface CalibrationFrame { id: number type: FrameType - camera: string + name: string filter?: string exposureTime: number temperature: number @@ -18,6 +18,11 @@ export interface CalibrationFrame { export interface CalibrationFrameGroup { id: number - key: Omit + name: string + key: Omit frames: CalibrationFrame[] } + +export interface CalibrationPreference { + openPath?: string +} diff --git a/desktop/src/shared/types/camera.types.ts b/desktop/src/shared/types/camera.types.ts index 2400c2e09..e97040128 100644 --- a/desktop/src/shared/types/camera.types.ts +++ b/desktop/src/shared/types/camera.types.ts @@ -158,6 +158,7 @@ export interface CameraStartCapture { filterPosition?: number shutterPosition?: number focusOffset?: number + calibrationGroup?: string } export const EMPTY_CAMERA_START_CAPTURE: CameraStartCapture = { @@ -196,23 +197,21 @@ export function updateCameraStartCaptureFromCamera(request: CameraStartCapture, if (camera.maxBinY > 1) request.binY = Math.max(1, Math.min(request.binY, camera.maxBinY)) if (camera.gainMax) request.gain = Math.max(camera.gainMin, Math.min(request.gain, camera.gainMax)) if (camera.offsetMax) request.offset = Math.max(camera.offsetMin, Math.min(request.offset, camera.offsetMax)) - if (!request.frameFormat || !camera.frameFormats.includes(request.frameFormat)) request.frameFormat = camera.frameFormats[0] + if (camera.frameFormats.length && (!request.frameFormat || !camera.frameFormats.includes(request.frameFormat))) request.frameFormat = camera.frameFormats[0] } -export interface CameraCaptureElapsed extends MessageEvent { +export interface CameraCaptureEvent extends MessageEvent { camera: Camera exposureAmount: number exposureCount: number captureElapsedTime: number captureProgress: number captureRemainingTime: number - exposureProgress: number - exposureRemainingTime: number - waitRemainingTime: number - waitProgress: number + stepElapsedTime: number + stepProgress: number + stepRemainingTime: number savePath?: string state: CameraCaptureState - aborted?: boolean } export type CameraCaptureState = 'IDLE' | 'CAPTURE_STARTED' | 'EXPOSURE_STARTED' | 'EXPOSURING' | 'WAITING' | 'SETTLING' | 'EXPOSURE_FINISHED' | 'CAPTURE_FINISHED' @@ -238,16 +237,16 @@ export const EMPTY_CAMERA_PREFERENCE: CameraPreference = { subFrame: false, } -export interface CameraExposureInfo { - count: number +export interface CameraStepInfo { remainingTime: number progress: number + elapsedTime: number } -export const EMPTY_CAMERA_EXPOSURE_INFO: CameraExposureInfo = { - count: 0, +export const EMPTY_CAMERA_STEP_INFO: CameraStepInfo = { remainingTime: 0, progress: 0, + elapsedTime: 0, } export interface CameraCaptureInfo { @@ -256,6 +255,7 @@ export interface CameraCaptureInfo { remainingTime: number elapsedTime: number progress: number + count: number } export const EMPTY_CAMERA_CAPTURE_INFO: CameraCaptureInfo = { @@ -264,14 +264,5 @@ export const EMPTY_CAMERA_CAPTURE_INFO: CameraCaptureInfo = { remainingTime: 0, elapsedTime: 0, progress: 0, -} - -export interface CameraWaitInfo { - remainingTime: number - progress: number -} - -export const EMPTY_CAMERA_WAIT_INFO: CameraWaitInfo = { - remainingTime: 0, - progress: 0, + count: 0, } diff --git a/desktop/src/shared/types/flat-wizard.types.ts b/desktop/src/shared/types/flat-wizard.types.ts index f3919befc..ab4f419c1 100644 --- a/desktop/src/shared/types/flat-wizard.types.ts +++ b/desktop/src/shared/types/flat-wizard.types.ts @@ -1,7 +1,7 @@ -import { CameraCaptureElapsed, CameraStartCapture } from './camera.types' +import { CameraCaptureEvent, CameraStartCapture } from './camera.types' export interface FlatWizardRequest { - captureRequest: CameraStartCapture + capture: CameraStartCapture exposureMin: number exposureMax: number meanTarget: number @@ -10,10 +10,9 @@ export interface FlatWizardRequest { export type FlatWizardState = 'EXPOSURING' | 'CAPTURED' | 'FAILED' -export interface FlatWizardElapsed { +export interface FlatWizardEvent { state: FlatWizardState exposureTime: number - capture?: CameraCaptureElapsed + capture?: CameraCaptureEvent savedPath?: string - message?: string } diff --git a/desktop/src/shared/types/focuser.types.ts b/desktop/src/shared/types/focuser.types.ts index eb1e2f8ca..50bf8c5ea 100644 --- a/desktop/src/shared/types/focuser.types.ts +++ b/desktop/src/shared/types/focuser.types.ts @@ -33,10 +33,6 @@ export const EMPTY_FOCUSER: Focuser = { temperature: 0 } -export function focuserPreferenceKey(focuser: Focuser) { - return `focuser.${focuser.name}` -} - export interface FocuserPreference { stepsRelative?: number stepsAbsolute?: number diff --git a/desktop/src/shared/types/home.types.ts b/desktop/src/shared/types/home.types.ts index a52fb11d9..fd22c33fc 100644 --- a/desktop/src/shared/types/home.types.ts +++ b/desktop/src/shared/types/home.types.ts @@ -1,6 +1,7 @@ import { Camera } from './camera.types' import { Focuser } from './focuser.types' import { Mount } from './mount.types' +import { Rotator } from './rotator.types' import { FilterWheel } from './wheel.types' export type HomeWindowType = 'CAMERA' | 'MOUNT' | 'GUIDER' | 'WHEEL' | 'FOCUSER' | 'DOME' | 'ROTATOR' | 'SWITCH' | @@ -35,10 +36,15 @@ export interface ConnectionClosed { id: string } +export interface HomePreference { + imagePath?: string +} + export interface Equipment { camera?: Camera guider?: Camera mount?: Mount focuser?: Focuser wheel?: FilterWheel + rotator?: Rotator } diff --git a/desktop/src/shared/types/image.types.ts b/desktop/src/shared/types/image.types.ts index 01d49d0cc..e169ab0d7 100644 --- a/desktop/src/shared/types/image.types.ts +++ b/desktop/src/shared/types/image.types.ts @@ -1,22 +1,26 @@ import { Point, Size } from 'electron' import { Angle, AstronomicalObject, DeepSkyObject, EquatorialCoordinateJ2000, Star } from './atlas.types' -import { Camera } from './camera.types' +import { Camera, CameraStartCapture } from './camera.types' import { PlateSolverType } from './settings.types' -export type ImageChannel = 'RED' | 'GREEN' | 'BLUE' | 'GRAY' | 'NONE' +export type ImageChannel = 'RED' | 'GREEN' | 'BLUE' | 'GRAY' export const SCNR_PROTECTION_METHODS = ['MAXIMUM_MASK', 'ADDITIVE_MASK', 'AVERAGE_NEUTRAL', 'MAXIMUM_NEUTRAL', 'MINIMUM_NEUTRAL'] as const export type SCNRProtectionMethod = (typeof SCNR_PROTECTION_METHODS)[number] export type ImageSource = 'FRAMING' | 'PATH' | 'CAMERA' | 'FLAT_WIZARD' +export type ImageFormat = 'FITS' | 'XISF' | 'PNG' | 'JPG' + +export type Bitpix = 'BYTE' | 'SHORT' | 'INTEGER' | 'LONG' | 'FLOAT' | 'DOUBLE' + export interface FITSHeaderItem { name: string value: string } export interface ImageInfo { - camera: Camera + camera?: Camera path: string width: number height: number @@ -28,6 +32,7 @@ export interface ImageInfo { declination?: Angle solved?: ImageSolved headers: FITSHeaderItem[] + bitpix: Bitpix statistics: ImageStatistics } @@ -40,6 +45,7 @@ export interface ImageAnnotation { } export interface ImageSolved extends EquatorialCoordinateJ2000 { + solved: boolean orientation: number scale: number width: number @@ -48,6 +54,7 @@ export interface ImageSolved extends EquatorialCoordinateJ2000 { } export const EMPTY_IMAGE_SOLVED: ImageSolved = { + solved: false, orientation: 0, scale: 0, width: 0, @@ -82,6 +89,16 @@ export interface ImageStatisticsBitOption { bitLength: number } +export const IMAGE_STATISTICS_BIT_OPTIONS: ImageStatisticsBitOption[] = [ + { name: 'Normalized: [0, 1]', rangeMax: 1, bitLength: 16 }, + { name: '8-bit: [0, 255]', rangeMax: 255, bitLength: 8 }, + { name: '9-bit: [0, 511]', rangeMax: 511, bitLength: 9 }, + { name: '10-bit: [0, 1023]', rangeMax: 1023, bitLength: 10 }, + { name: '12-bit: [0, 4095]', rangeMax: 4095, bitLength: 12 }, + { name: '14-bit: [0, 16383]', rangeMax: 16383, bitLength: 14 }, + { name: '16-bit: [0, 65535]', rangeMax: 65535, bitLength: 16 }, +] as const + export interface ImageStatistics { count: number maxCount: number @@ -98,6 +115,7 @@ export interface ImageStatistics { export interface ImagePreference { solverRadius?: number solverType?: PlateSolverType + savePath?: string } export const EMPTY_IMAGE_PREFERENCE: ImagePreference = { @@ -110,6 +128,7 @@ export interface ImageData { path?: string source?: ImageSource title?: string + capture?: CameraStartCapture } export interface FOV { @@ -164,3 +183,87 @@ export interface FOVTelescope extends FOVEquipment { aperture: number focalLength: number } + +export interface ImageSCNRDialog { + showDialog: boolean + channel?: ImageChannel + amount: number + method: SCNRProtectionMethod +} + +export interface ImageDetectStars { + visible: boolean + stars: DetectedStar[] +} + +export interface ImageFITSHeadersDialog { + showDialog: boolean + headers: FITSHeaderItem[] +} + +export interface ImageStretchDialog { + showDialog: boolean + auto: boolean + shadow: number + highlight: number + midtone: number +} + +export interface ImageSolverDialog { + showDialog: boolean + solving: boolean + blind: boolean + centerRA: Angle + centerDEC: Angle + radius: number + readonly solved: ImageSolved + readonly types: PlateSolverType[] + type: PlateSolverType +} + +export interface ImageFOVDialog extends FOV { + showDialog: boolean + fovs: FOV[] + edited?: FOV + showCameraDialog: boolean + cameras: FOVCamera[] + camera?: FOVCamera + showTelescopeDialog: boolean + telescopes: FOVTelescope[] + telescope?: FOVTelescope +} + +export interface ImageROI { + x: number + y: number + width: number + height: number +} + +export interface ImageSaveDialog { + showDialog: boolean + format: ImageFormat + bitpix: Bitpix + shouldBeTransformed: boolean + transformation: ImageTransformation + path: string +} + +export interface ImageTransformation { + force: boolean + calibrationGroup?: string + debayer: boolean + stretch: Omit + mirrorHorizontal: boolean + mirrorVertical: boolean + invert: boolean + scnr: Pick +} + +export interface ImageAnnotationDialog { + showDialog: boolean + useStarsAndDSOs: boolean + useMinorPlanets: boolean + minorPlanetsMagLimit: number + useSimbad: boolean +} diff --git a/desktop/src/shared/types/mount.types.ts b/desktop/src/shared/types/mount.types.ts index ddb5dbc65..e09bdaf24 100644 --- a/desktop/src/shared/types/mount.types.ts +++ b/desktop/src/shared/types/mount.types.ts @@ -1,4 +1,4 @@ -import { EquatorialCoordinate } from './atlas.types' +import { Angle, EquatorialCoordinate } from './atlas.types' import { GPS } from './gps.types' import { GuideOutput } from './guider.types' @@ -8,7 +8,9 @@ export type TargetCoordinateType = 'J2000' | 'JNOW' export type TrackMode = 'SIDEREAL' | ' LUNAR' | 'SOLAR' | 'KING' | 'CUSTOM' -export type CelestialLocationType = 'ZENITH' | 'NORTH_POLE' | 'SOUTH_POLE' | 'GALACTIC_CENTER' | 'MERIDIAN_EQUATOR' | 'MERIDIAN_ECLIPTIC' +export type CelestialLocationType = 'ZENITH' | 'NORTH_POLE' | 'SOUTH_POLE' | 'GALACTIC_CENTER' | 'MERIDIAN_EQUATOR' | 'MERIDIAN_ECLIPTIC' | 'EQUATOR_ECLIPTIC' + +export type MountRemoteControlType = 'LX200' | 'STELLARIUM' export interface SlewRate { name: string @@ -68,3 +70,26 @@ export const EMPTY_MOUNT: Mount = { parking: false, parked: false } + +export interface MountRemoteControl { + type: MountRemoteControlType + mount: Mount + running: boolean + rightAscension: Angle + declination: Angle + latitude: Angle + longitude: Angle + slewing: boolean + tracking: boolean + parked: boolean + host: string + port: number +} + +export interface MountRemoteControlDialog { + showDialog: boolean + type: MountRemoteControlType + host: string + port: number + data: MountRemoteControl[] +} diff --git a/desktop/src/shared/types/rotator.types.ts b/desktop/src/shared/types/rotator.types.ts new file mode 100644 index 000000000..4348ce342 --- /dev/null +++ b/desktop/src/shared/types/rotator.types.ts @@ -0,0 +1,35 @@ +import { Device } from './device.types' + +export interface Rotator extends Device { + moving: boolean + angle: number + canAbort: boolean + canReverse: boolean + reversed: boolean + canSync: boolean + canHome: boolean + hasBacklashCompensation: boolean + minAngle: number + maxAngle: number +} + +export const EMPTY_ROTATOR: Rotator = { + sender: '', + id: '', + name: '', + moving: false, + angle: 0, + canAbort: false, + canReverse: false, + reversed: false, + canSync: false, + canHome: false, + hasBacklashCompensation: false, + minAngle: 0, + maxAngle: 0, + connected: false +} + +export interface RotatorPreference { + angle?: number +} diff --git a/desktop/src/shared/types/sequencer.types.ts b/desktop/src/shared/types/sequencer.types.ts index a40396f57..58996386d 100644 --- a/desktop/src/shared/types/sequencer.types.ts +++ b/desktop/src/shared/types/sequencer.types.ts @@ -1,5 +1,6 @@ -import { AutoSubFolderMode, Camera, CameraCaptureElapsed, CameraStartCapture, Dither } from './camera.types' +import { AutoSubFolderMode, Camera, CameraCaptureEvent, CameraStartCapture, Dither } from './camera.types' import { Focuser } from './focuser.types' +import { Mount } from './mount.types' import { FilterWheel } from './wheel.types' export type SequenceCaptureMode = 'FULLY' | 'INTERLEAVED' @@ -35,6 +36,7 @@ export interface SequencePlan { dither: Dither autoFocus: AutoFocusAfterConditions camera?: Camera + mount?: Mount wheel?: FilterWheel focuser?: Focuser } @@ -65,10 +67,10 @@ export const EMPTY_SEQUENCE_PLAN: SequencePlan = { }, } -export interface SequencerElapsed extends MessageEvent { +export interface SequencerEvent extends MessageEvent { id: number elapsedTime: number remainingTime: number progress: number - capture?: CameraCaptureElapsed + capture?: CameraCaptureEvent } diff --git a/desktop/src/shared/types/settings.types.ts b/desktop/src/shared/types/settings.types.ts index 5abda4196..d3eeacabb 100644 --- a/desktop/src/shared/types/settings.types.ts +++ b/desktop/src/shared/types/settings.types.ts @@ -1,10 +1,9 @@ -import { KeyValue } from '@angular/common' export type PlateSolverType = 'ASTROMETRY_NET' | 'ASTROMETRY_NET_ONLINE' | 'ASTAP' export const DEFAULT_SOLVER_TYPES: PlateSolverType[] = ['ASTROMETRY_NET_ONLINE', 'ASTAP'] -export interface PlateSolverOptions { +export interface PlateSolverPreference { type: PlateSolverType executablePath: string downsampleFactor: number @@ -13,13 +12,11 @@ export interface PlateSolverOptions { timeout: number } -export const EMPTY_PLATE_SOLVER_OPTIONS: PlateSolverOptions = { - type: 'ASTROMETRY_NET_ONLINE', +export const EMPTY_PLATE_SOLVER_PREFERENCE: PlateSolverPreference = { + type: 'ASTAP', executablePath: '', downsampleFactor: 0, apiUrl: 'https://nova.astrometry.net/', apiKey: '', timeout: 600, } - -export type DatabaseEntry = KeyValue diff --git a/desktop/src/shared/utils/coordinate-interpolation.ts b/desktop/src/shared/utils/coordinate-interpolation.ts index ce49becfa..38b8cd927 100644 --- a/desktop/src/shared/utils/coordinate-interpolation.ts +++ b/desktop/src/shared/utils/coordinate-interpolation.ts @@ -1,9 +1,9 @@ -import { Angle } from '../types/atlas.types' +import { Angle, EquatorialCoordinateJ2000 } from '../types/atlas.types' import { degreesToRadians, formatAngle } from './angle' import { BicubicSplineInterpolation } from './bicubic-interpolation' -import { TimeRepresentation, longitudeDegreesConstrained, obliquity, rectangularEquatorialToEcliptic, rectangularEquatorialToGalactic, rectangularToSphericalDegreesConstrained, sphericalToRectangular } from './ephemeris' +import { SphericalRepresentation, TimeRepresentation, longitudeDegreesConstrained, obliquity, rectangularEquatorialToEcliptic, rectangularEquatorialToGalactic, rectangularToSphericalDegreesConstrained, sphericalToRectangular } from './ephemeris' -export interface InterpolatedCoordinate { +export interface InterpolatedCoordinate extends EquatorialCoordinateJ2000 { alpha: T delta: T l?: T @@ -60,10 +60,12 @@ export class CoordinateInterpolator { const coordinate: InterpolatedCoordinate = { alpha: alpha, delta: delta, + rightAscensionJ2000: formatAngle(alpha / 15, 24, false, this.precision), + declinationJ2000: formatAngle(delta, 0, true, this.precision), } if (withGalactic || withEcliptic) { - const s = { + const s: SphericalRepresentation = { lon: degreesToRadians(alpha), lat: degreesToRadians(delta), } @@ -90,8 +92,10 @@ export class CoordinateInterpolator { const q = this.interpolate(x, y, withGalactic, withEcliptic) const coordinate: InterpolatedCoordinate = { - alpha: formatAngle(q.alpha / 15, 24, false, this.precision + 1, units), - delta: formatAngle(q.delta, 0, true, this.precision, units) + alpha: q.rightAscensionJ2000 as string, + delta: q.declinationJ2000 as string, + rightAscensionJ2000: q.rightAscensionJ2000, + declinationJ2000: q.declinationJ2000 } if (q.l !== undefined && q.b !== undefined) { diff --git a/desktop/src/styles.scss b/desktop/src/styles.scss index 72f564196..b1a1660ff 100644 --- a/desktop/src/styles.scss +++ b/desktop/src/styles.scss @@ -135,6 +135,7 @@ p-dropdown, .p-button { white-space: nowrap; + max-width: 100%; &.p-button-icon-only { min-width: fit-content; @@ -180,16 +181,6 @@ i.mdi { display: inline-flex; } -.p-dialog-content { - .p-slidemenu { - width: auto !important; - } - - &:has(.p-slidemenu) { - padding: 0 !important; - } -} - .p-menuitem-link { &.p-menuitem-checked { background-color: #C5E1A5; @@ -235,10 +226,23 @@ p-contextmenu *, p-dropdown *, p-dropdownitem *, .p-multiselect-header *, -.no-draggable-region { +.no-draggable-region, +.p-button.p-dialog-header-close { -webkit-app-region: no-drag; } +.p-dialog .p-dialog-header .p-dialog-header-icon { + min-width: 3rem; + width: 3rem; + height: 3rem; +} + +.p-dialog .p-dialog-content:has(neb-slide-menu) { + background: #1e1e1e; + color: rgba(255, 255, 255, 0.87); + padding: 1.25rem 1.25rem 1.25rem 1.25rem; +} + .pixelated { image-rendering: pixelated; } @@ -255,6 +259,150 @@ p-dropdownitem *, gap: 1px; } +.min-w-0 { + min-width: 0px !important; +} + +.min-w-full { + min-width: 100% !important; +} + +.min-w-screen { + min-width: 100vw !important; +} + +.min-w-min { + min-width: min-content !important; +} + +.min-w-min { + min-width: min-content !important; +} + +.min-w-fit { + min-width: fit-content !important; +} + +.min-w-1rem { + min-width: 1rem !important; +} + +.min-w-2rem { + min-width: 2rem !important; +} + +.min-w-3rem { + min-width: 3rem !important; +} + +.min-w-4rem { + min-width: 4rem !important; +} + +.min-w-5rem { + min-width: 5rem !important; +} + +.min-w-6rem { + min-width: 6rem !important; +} + +.min-w-7rem { + min-width: 7rem !important; +} + +.min-w-8rem { + min-width: 8rem !important; +} + +.min-w-9rem { + min-width: 9rem !important; +} + +.min-w-10rem { + min-width: 10rem !important; +} + +.min-w-11rem { + min-width: 11rem !important; +} + +.min-w-12rem { + min-width: 12rem !important; +} + +.min-w-13rem { + min-width: 13rem !important; +} + +.min-w-14rem { + min-width: 14rem !important; +} + +.min-w-15rem { + min-width: 15rem !important; +} + +.min-w-16rem { + min-width: 16rem !important; +} + +.min-w-17rem { + min-width: 17rem !important; +} + +.min-w-18rem { + min-width: 18rem !important; +} + +.min-w-19rem { + min-width: 19rem !important; +} + +.min-w-20rem { + min-width: 20rem !important; +} + +.min-w-21rem { + min-width: 21rem !important; +} + +.min-w-22rem { + min-width: 22rem !important; +} + +.min-w-23rem { + min-width: 23rem !important; +} + +.min-w-24rem { + min-width: 24rem !important; +} + +.min-w-25rem { + min-width: 25rem !important; +} + +.min-w-26rem { + min-width: 26rem !important; +} + +.min-w-27rem { + min-width: 27rem !important; +} + +.min-w-28rem { + min-width: 28rem !important; +} + +.min-w-29rem { + min-width: 29rem !important; +} + +.min-w-30rem { + min-width: 30rem !important; +} + ::-webkit-scrollbar { width: 6px; height: 6px; diff --git a/desktop/src/typings.d.ts b/desktop/src/typings.d.ts index 5ac88f709..eeec77809 100644 --- a/desktop/src/typings.d.ts +++ b/desktop/src/typings.d.ts @@ -7,6 +7,7 @@ interface NodeModule { interface Window { process: any require: any + apiHost: string apiPort: number options: { icon?: string diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Line.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Line.kt new file mode 100644 index 000000000..76bb1a4d2 --- /dev/null +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Line.kt @@ -0,0 +1,44 @@ +package nebulosa.alignment.polar.point.three + +internal data class Line(@JvmField val slope: Double, @JvmField val intercept: Double) { + + inline val isVertical + get() = !slope.isFinite() + + inline val isHorizontal + get() = slope == 0.0 + + fun intersectionWith(other: Line): DoubleArray { + val slope2 = other.slope + val intercept2 = other.intercept + + val vertical1 = isVertical + val vertical2 = other.isVertical + + if (slope == slope2 || (vertical1 && vertical2)) { + throw IllegalArgumentException("identical lines do not have an intersection point.") + } else if (vertical1) { + return doubleArrayOf(intercept, slope2 * intercept + intercept2) + } else if (vertical2) { + return doubleArrayOf(intercept2, slope * intercept2 + intercept) + } else { + val x = (intercept2 - intercept) / (slope - slope2) + return doubleArrayOf(x, slope * x + intercept) + } + } + + companion object { + + @JvmStatic + fun fromPoints(p0: DoubleArray, p1: DoubleArray): Line { + return fromPoints(p0[0], p0[1], p1[0], p1[1]) + } + + @JvmStatic + fun fromPoints(x0: Double, y0: Double, x1: Double, y1: Double): Line { + val k = (y1 - y0) / (x1 - x0) + val b = if (!k.isFinite()) x0 else y0 - k * x0 + return Line(k, b) + } + } +} diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/PolarErrorDetermination.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/PolarErrorDetermination.kt index 5e1aef6be..169071fde 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/PolarErrorDetermination.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/PolarErrorDetermination.kt @@ -4,9 +4,16 @@ import nebulosa.constants.PI import nebulosa.constants.TAU import nebulosa.math.Angle import nebulosa.math.Vector3D +import nebulosa.math.cos +import nebulosa.math.sin +import nebulosa.plate.solving.PlateSolution +import nebulosa.time.InstantOfTime +import nebulosa.time.UTC import kotlin.math.abs +import kotlin.math.hypot internal data class PolarErrorDetermination( + @JvmField val initialReferenceFrame: PlateSolution, @JvmField val firstPosition: Position, @JvmField val secondPosition: Position, @JvmField val thirdPosition: Position, @@ -17,13 +24,19 @@ internal data class PolarErrorDetermination( private inline val isNorthern get() = latitude > 0.0 - @JvmField val plane = with(Vector3D.plane(firstPosition.vector, secondPosition.vector, thirdPosition.vector)) { + private val plane = with(Vector3D.plane(firstPosition.vector, secondPosition.vector, thirdPosition.vector)) { // Flip vector if pointing to the wrong direction. if (isNorthern && x < 0 || !isNorthern && x > 0) -normalized else normalized } - @JvmField val errorPosition = Position(plane, longitude, latitude) + @JvmField val initialErrorPosition = Position(plane, longitude, latitude) + @Volatile var currentReferenceFrame = initialReferenceFrame + private set + + /** + * Computes the initial azimuth and altitude errors. + */ fun compute(): DoubleArray { val altitudeError: Double var azimuthError: Double @@ -31,11 +44,11 @@ internal data class PolarErrorDetermination( val pole = abs(latitude) if (isNorthern) { - altitudeError = errorPosition.topocentric.altitude - pole - azimuthError = errorPosition.topocentric.azimuth + altitudeError = initialErrorPosition.topocentric.altitude - pole + azimuthError = initialErrorPosition.topocentric.azimuth } else { - altitudeError = pole - errorPosition.topocentric.altitude - azimuthError = errorPosition.topocentric.azimuth + PI + altitudeError = pole - initialErrorPosition.topocentric.altitude + azimuthError = initialErrorPosition.topocentric.azimuth + PI } if (azimuthError > PI) { @@ -47,4 +60,154 @@ internal data class PolarErrorDetermination( return doubleArrayOf(azimuthError, altitudeError) } + + /** + * Computes the updated azimuth and altitude errors from initial errors by [compute]. + */ + fun update( + time: InstantOfTime, + initialAzimuthError: Angle, initialAltitudeError: Angle, + referenceFrame: PlateSolution, compensateRefraction: Boolean = false + ): DoubleArray { + currentReferenceFrame = referenceFrame + + val centerX = referenceFrame.widthInPixels / 2.0 + val centerY = referenceFrame.heightInPixels / 2.0 + + val originPixel = + doubleArrayOf(initialReferenceFrame.rightAscension, initialReferenceFrame.declination).stenographicProjection(referenceFrame) + val pointShift = doubleArrayOf(centerX - originPixel[0], centerY - originPixel[1]) + + originPixel[0] += pointShift[0] * 2.0 + originPixel[1] += pointShift[1] * 2.0 + + val destinationAltAz = destinationCoordinates(-initialAzimuthError, -initialAltitudeError, time, compensateRefraction) + val destinationPixel = doubleArrayOf(destinationAltAz[0], destinationAltAz[1]).stenographicProjection(referenceFrame) + + destinationPixel[0] += pointShift[0] * 2.0 + destinationPixel[1] += pointShift[1] * 2.0 + + // Azimuth. + val originalAzimuthAltAz = destinationCoordinates(-initialAzimuthError, 0.0, time, compensateRefraction) + val originalAzimuthPixel = doubleArrayOf(originalAzimuthAltAz[0], originalAzimuthAltAz[1]).stenographicProjection(referenceFrame) + + originalAzimuthPixel[0] += pointShift[0] * 2.0 + originalAzimuthPixel[1] += pointShift[1] * 2.0 + + val lineOriginToAzimuth = Line.fromPoints(originPixel, originalAzimuthPixel) + val correctedAzimuthLine = Line(lineOriginToAzimuth.slope, centerY - lineOriginToAzimuth.slope * centerX) + val lineAzimuthToDestination = Line.fromPoints(originalAzimuthPixel, destinationPixel) + val correctedAzimuthPixel = lineAzimuthToDestination.intersectionWith(correctedAzimuthLine) + val correctedAzimuthDistance = hypot(correctedAzimuthPixel[0] - centerX, correctedAzimuthPixel[1] - centerY) + val originalAzimuthDistance = hypot(originalAzimuthPixel[0] - originPixel[0], originalAzimuthPixel[1] - originPixel[1]) + + // Altitude. + val originalAltitudeAltAz = destinationCoordinates(0.0, -initialAltitudeError, time, compensateRefraction) + val originalAltitudePixel = doubleArrayOf(originalAltitudeAltAz[0], originalAltitudeAltAz[1]).stenographicProjection(referenceFrame) + + originalAltitudePixel[0] += pointShift[0] * 2.0 + originalAltitudePixel[1] += pointShift[1] * 2.0 + + val lineOriginToAltitude = Line.fromPoints(originPixel, originalAltitudePixel) + val correctedAltitudeLine = Line(lineOriginToAltitude.slope, centerY - lineOriginToAltitude.slope * centerX) + val lineAltitudeToDestination = Line.fromPoints(originalAltitudePixel, destinationPixel) + val correctedAltitudePixel = lineAltitudeToDestination.intersectionWith(correctedAltitudeLine) + val correctedAltitudeDistance = hypot(correctedAltitudePixel[0] - centerX, correctedAltitudePixel[1] - centerY) + val originalAltitudeDistance = hypot(originalAltitudePixel[0] - originPixel[0], originalAltitudePixel[1] - originPixel[1]) + + // Check if sign needs to be reversed. + + // Azimuth. + val originalAzimuthDirection = doubleArrayOf(destinationPixel[0] - originalAltitudePixel[0], destinationPixel[1] - originalAltitudePixel[1]) + val correctedAzimuthDirection = + doubleArrayOf(destinationPixel[0] - correctedAltitudePixel[0], destinationPixel[1] - correctedAltitudePixel[1]) + // When dot product is positive, the angle between both vectors is smaller than 90°. + val azimuthSameDirection = + (originalAzimuthDirection[0] * correctedAzimuthDirection[0] + originalAzimuthDirection[1] * correctedAzimuthDirection[1]) > 0 + val azSign = if (azimuthSameDirection) 1 else -1 + + // Altitude. + val originalAltitudeDirection = doubleArrayOf(destinationPixel[0] - originalAzimuthPixel[0], destinationPixel[1] - originalAzimuthPixel[1]) + val correctedAltitudeDirection = doubleArrayOf(destinationPixel[0] - correctedAzimuthPixel[0], destinationPixel[1] - correctedAzimuthPixel[1]) + // When dot product is positive, the angle between both vectors is smaller than 90°. + val altitudeSameDirection = + (originalAltitudeDirection[0] * correctedAltitudeDirection[0] + originalAltitudeDirection[1] * correctedAltitudeDirection[1]) > 0 + val altSign = if (altitudeSameDirection) 1 else -1 + + // Error determination. + val currentAzimuthError = initialAzimuthError * (azSign * correctedAzimuthDistance / originalAzimuthDistance) + val currentAltitudeError = initialAltitudeError * (altSign * correctedAltitudeDistance / originalAltitudeDistance) + + return doubleArrayOf(currentAzimuthError, currentAltitudeError) + } + + fun destinationCoordinates(azimuth: Angle, altitude: Angle, time: InstantOfTime = UTC.now(), compensateRefraction: Boolean = false): DoubleArray { + val (_, _, _, rightAscension, declination) = initialReferenceFrame + val position = Position(rightAscension, declination, longitude, latitude, time, compensateRefraction) + // First rotate by azimuth, then from the point at azimuth rotate further by altitude to get to the final position + val azDest = Vector3D.rotateByRodrigues(position.vector, Vector3D.Z, azimuth) + val rotatedAltAxis = Vector3D.rotateByRodrigues(Vector3D.Y, Vector3D.Z, azimuth) + val finalDest = Vector3D.rotateByRodrigues(azDest, rotatedAltAxis, altitude) // Combination of first az then applied alt. + return Position(finalDest, longitude, latitude).topocentric.transform(time, compensateRefraction) + } + + companion object { + + /** + * Generates a Point with relative X/Y values for centering the current coordinates relative + * to a given point using steonographic projection. + */ + @JvmStatic + internal fun DoubleArray.stenographicProjection( + solution: PlateSolution, + centerRA: Angle = solution.rightAscension, + centerDEC: Angle = solution.declination + ) = stenographicProjection( + centerRA, centerDEC, + solution.widthInPixels / 2.0, solution.heightInPixels / 2.0, solution.scale, solution.orientation + ) + + @JvmStatic + internal fun DoubleArray.stenographicProjection( + centerRA: Angle, centerDEC: Angle, + centerX: Double, centerY: Double, scale: Angle, orientation: Angle + ): DoubleArray { + var targetRA = this[0] + val deltaRA = targetRA - centerRA + + if (deltaRA > PI) { + targetRA -= TAU + } else if (deltaRA < -PI) { + targetRA += TAU + } + + val targetDEC = this[1] + + val targetDECSin = targetDEC.sin + val targetDECCos = targetDEC.cos + + val centerDECSin = centerDEC.sin + val centerDECCos = centerDEC.cos + + val raDiff = targetRA - centerRA + val raDiffCos = raDiff.cos + + val rotationSin = orientation.sin + val rotationCos = orientation.cos + + val dd = 2.0 / (1.0 + targetDECSin * centerDECSin + targetDECCos * centerDECCos * raDiffCos) + val raMod = dd * raDiff.sin * targetDECCos + val decMod = dd * (targetDECSin * centerDECCos - targetDECCos * centerDECSin * raDiffCos) + + var deltaX = raMod + var deltaY = decMod + + if (orientation != 0.0) { + deltaX = raMod * rotationCos + decMod * rotationSin + deltaY = decMod * rotationCos - raMod * rotationSin + } + + return doubleArrayOf(centerX - deltaX / scale, centerY - deltaY / scale) + } + } } diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt index fb28668e4..246f2e359 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Position.kt @@ -5,7 +5,7 @@ import nebulosa.erfa.eraAtco13 import nebulosa.math.Angle import nebulosa.math.ONE_ATM import nebulosa.math.Vector3D -import nebulosa.time.CurrentTime +import nebulosa.math.normalized import nebulosa.time.IERS import nebulosa.time.InstantOfTime import kotlin.math.cos @@ -21,7 +21,7 @@ internal data class Position( operator fun invoke( rightAscension: Angle, declination: Angle, longitude: Angle, latitude: Angle, - time: InstantOfTime = CurrentTime, + time: InstantOfTime, compensateRefraction: Boolean = false, ): Position { // SOFA.CelestialToTopocentric. @@ -33,23 +33,18 @@ internal data class Position( // @formatter:on val topocentric = Topocentric(b[0], PIOVERTWO - b[1], longitude, latitude) // val vector = CartesianCoordinate.of(-b[0], b[1], 1.0) - val theta = -b[0] + val theta = -topocentric.azimuth val phi = b[1] - val x = cos(theta) * sin(phi) - val y = sin(theta) * sin(phi) + val sp = sin(phi) + val x = cos(theta) * sp + val y = sin(theta) * sp val z = cos(phi) return Position(topocentric, Vector3D(x, y, z)) } - operator fun invoke( - vector: Vector3D, longitude: Angle, latitude: Angle, - ): Position { - val topocentric = if (vector.x == 0.0 && vector.y == 0.0) { - Topocentric(0.0, PIOVERTWO, longitude, latitude) - } else { - Topocentric(-vector.longitude, PIOVERTWO - vector.latitude, longitude, latitude) - } - + operator fun invoke(vector: Vector3D, longitude: Angle, latitude: Angle): Position { + val topocentric = if (vector.x == 0.0 && vector.y == 0.0) Topocentric(0.0, PIOVERTWO, longitude, latitude) + else Topocentric((-vector.longitude).normalized, PIOVERTWO - vector.latitude, longitude, latitude) return Position(topocentric, vector) } } diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt index 91e8a5bb3..3f0f5c33c 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignment.kt @@ -1,5 +1,7 @@ package nebulosa.alignment.polar.point.three +import nebulosa.alignment.polar.point.three.ThreePointPolarAlignmentResult.* +import nebulosa.common.Resettable import nebulosa.common.concurrency.cancel.CancellationToken import nebulosa.constants.DEG2RAD import nebulosa.math.Angle @@ -8,7 +10,6 @@ import nebulosa.plate.solving.PlateSolver import nebulosa.plate.solving.PlateSolvingException import nebulosa.time.UTC import java.nio.file.Path -import kotlin.math.min /** * Three Point Polar Alignment almost anywhere in the sky. @@ -21,45 +22,90 @@ data class ThreePointPolarAlignment( private val solver: PlateSolver, private val longitude: Angle, private val latitude: Angle, -) { +) : Resettable { - private val positions = arrayOfNulls(3) + enum class State { + FIRST_POSITION, + SECOND_POSITION, + THIRD_POSITION, + CONTINUOUS_SOLVE, + } + + @Volatile var state = State.FIRST_POSITION + private set + + @Volatile var initialAzimuthError: Angle = 0.0 + private set + + @Volatile var initialAltitudeError: Angle = 0.0 + private set - @Volatile var state = 0 + @Volatile var currentAzimuthError: Angle = 0.0 private set + @Volatile var currentAltitudeError: Angle = 0.0 + private set + + private lateinit var firstPosition: Position + private lateinit var secondPosition: Position + private lateinit var polarErrorDetermination: PolarErrorDetermination + fun align( path: Path, rightAscension: Angle, declination: Angle, radius: Angle = DEFAULT_RADIUS, compensateRefraction: Boolean = false, cancellationToken: CancellationToken = CancellationToken.NONE, ): ThreePointPolarAlignmentResult { + if (cancellationToken.isDone) { + return Cancelled + } + val solution = try { solver.solve(path, null, rightAscension, declination, radius, cancellationToken = cancellationToken) } catch (e: PlateSolvingException) { - return ThreePointPolarAlignmentResult.NoPlateSolution(e) + return NoPlateSolution(e) } - if (!solution.solved || cancellationToken.isCancelled) { - return ThreePointPolarAlignmentResult.NoPlateSolution(null) + if (cancellationToken.isDone) { + return Cancelled + } else if (!solution.solved) { + return NoPlateSolution(null) } else { val time = UTC.now() - positions[min(state, 2)] = solution.position(time, compensateRefraction) - - if (state++ >= 2) { - val polarErrorDetermination = PolarErrorDetermination(positions[0]!!, positions[1]!!, positions[2]!!, longitude, latitude) - val (azimuth, altitude) = polarErrorDetermination.compute() - return ThreePointPolarAlignmentResult.Measured(solution.rightAscension, solution.declination, azimuth, altitude) + when (state) { + State.CONTINUOUS_SOLVE -> { + val (azimuth, altitude) = polarErrorDetermination + .update(time, initialAzimuthError, initialAltitudeError, solution, compensateRefraction) + currentAzimuthError = azimuth + currentAltitudeError = altitude + return Measured(solution.rightAscension, solution.declination, azimuth, altitude) + } + State.THIRD_POSITION -> { + val position = solution.position(time, compensateRefraction) + polarErrorDetermination = PolarErrorDetermination(solution, firstPosition, secondPosition, position, longitude, latitude) + val (azimuth, altitude) = polarErrorDetermination.compute() + state = State.CONTINUOUS_SOLVE + initialAzimuthError = azimuth + initialAltitudeError = altitude + return Measured(solution.rightAscension, solution.declination, azimuth, altitude) + } + State.SECOND_POSITION -> { + secondPosition = solution.position(time, compensateRefraction) + state = State.THIRD_POSITION + } + State.FIRST_POSITION -> { + firstPosition = solution.position(time, compensateRefraction) + state = State.SECOND_POSITION + } } - return ThreePointPolarAlignmentResult.NeedMoreMeasurement(solution.rightAscension, solution.declination) + return NeedMoreMeasurement(solution.rightAscension, solution.declination) } } - fun reset() { - state = 0 - positions.fill(null) + override fun reset() { + state = State.FIRST_POSITION } private fun PlateSolution.position(time: UTC, compensateRefraction: Boolean): Position { @@ -68,6 +114,6 @@ data class ThreePointPolarAlignment( companion object { - const val DEFAULT_RADIUS: Angle = 4 * DEG2RAD + const val DEFAULT_RADIUS: Angle = 5 * DEG2RAD } } diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt index 6496728d5..ad76ecf00 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/ThreePointPolarAlignmentResult.kt @@ -13,4 +13,6 @@ sealed interface ThreePointPolarAlignmentResult { ) : ThreePointPolarAlignmentResult data class NoPlateSolution(@JvmField val exception: PlateSolvingException?) : ThreePointPolarAlignmentResult + + data object Cancelled : ThreePointPolarAlignmentResult } diff --git a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Topocentric.kt b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Topocentric.kt index ec1ca0366..5d753983c 100644 --- a/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Topocentric.kt +++ b/nebulosa-alignment/src/main/kotlin/nebulosa/alignment/polar/point/three/Topocentric.kt @@ -1,8 +1,24 @@ package nebulosa.alignment.polar.point.three +import nebulosa.constants.PIOVERTWO +import nebulosa.erfa.eraAtoc13 import nebulosa.math.Angle +import nebulosa.math.ONE_ATM +import nebulosa.time.IERS +import nebulosa.time.InstantOfTime +import nebulosa.time.UTC data class Topocentric( @JvmField val azimuth: Angle, @JvmField val altitude: Angle, @JvmField val longitude: Angle, @JvmField val latitude: Angle, -) +) { + + fun transform(time: InstantOfTime = UTC.now(), compensateRefraction: Boolean = false): DoubleArray { + val dut1 = IERS.delta(time) + val (xp, yp) = IERS.pmAngles(time) + val pressure = if (compensateRefraction) ONE_ATM else 0.0 + val zd = PIOVERTWO - altitude // zenith distance + + return eraAtoc13('A', azimuth, zd, time.utc.whole, time.utc.fraction, dut1, longitude, latitude, 0.0, xp, yp, pressure, 15.0, 0.5, 0.55) + } +} diff --git a/nebulosa-alignment/src/test/kotlin/ThreePointPolarAlignmentTest.kt b/nebulosa-alignment/src/test/kotlin/ThreePointPolarAlignmentTest.kt index 5d4f8eb01..9b9281d47 100644 --- a/nebulosa-alignment/src/test/kotlin/ThreePointPolarAlignmentTest.kt +++ b/nebulosa-alignment/src/test/kotlin/ThreePointPolarAlignmentTest.kt @@ -1,45 +1,55 @@ import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.doubles.plusOrMinus +import io.kotest.matchers.doubles.shouldBeExactly import io.kotest.matchers.shouldBe import nebulosa.alignment.polar.point.three.PolarErrorDetermination +import nebulosa.alignment.polar.point.three.PolarErrorDetermination.Companion.stenographicProjection import nebulosa.alignment.polar.point.three.Position +import nebulosa.math.arcsec import nebulosa.math.deg import nebulosa.math.hours import nebulosa.math.toArcsec -import nebulosa.time.IERS -import nebulosa.time.IERSA +import nebulosa.plate.solving.PlateSolution import nebulosa.time.TimeYMDHMS import nebulosa.time.UTC -import java.nio.file.Path -import kotlin.io.path.inputStream class ThreePointPolarAlignmentTest : StringSpec() { init { - val iersa = IERSA() - iersa.load(Path.of("../data/finals2000A.all").inputStream()) - IERS.attach(iersa) - // Based on logs generated by N.I.N.A. using Telescope Simulator for .NET and Sky Simulator (ASCOM). // https://sourceforge.net/projects/sky-simulator/ - "perfectly aligned" { - val position1 = Position("05:35:18".hours, "-05 23 26".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 10, 22, 58, 42.4979))) - position1.vector[0] shouldBe (0.301851589038 plusOrMinus 1e-4) - position1.vector[1] shouldBe (-0.0681426041296783 plusOrMinus 1e-4) - position1.vector[2] shouldBe (0.950916507216938 plusOrMinus 1e-4) + "position" { + val a = Position("04:14:08".hours, "-05 26 10".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 10, 22, 59, 13.3739))) + val b = Position(a.vector, LNG, SLAT) + a.topocentric.azimuth shouldBeExactly b.topocentric.azimuth + a.topocentric.altitude shouldBeExactly b.topocentric.altitude + } + "stenographic projection" { + val coordinates = doubleArrayOf(5.0.arcsec, 8.0.arcsec) + val projected = coordinates.stenographicProjection(0.0, 0.0, 100.0, 100.0, 1.0.arcsec, 0.0) + projected[0] shouldBe (95.0 plusOrMinus 1e-8) + projected[1] shouldBe (92.0 plusOrMinus 1e-8) + } + "destination coordinates" { + val position1 = Position("05:35:18".hours, "-05 23 26".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 10, 22, 58, 42.4979))) val position2 = Position("04:54:45".hours, "-05 24 50".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 10, 22, 58, 58.1655))) - position2.vector[0] shouldBe (0.300426130373811 plusOrMinus 1e-4) - position2.vector[1] shouldBe (0.108903442814494 plusOrMinus 1e-4) - position2.vector[2] shouldBe (0.947567507005051 plusOrMinus 1e-4) - val position3 = Position("04:14:08".hours, "-05 26 10".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 10, 22, 59, 13.3739))) - position3.vector[0] shouldBe (0.286747300379159 plusOrMinus 1e-4) - position3.vector[1] shouldBe (0.282671401982864 plusOrMinus 1e-4) - position3.vector[2] shouldBe (0.915353955705828 plusOrMinus 1e-4) + val initialFrame = PlateSolution(true, 0.0, 1.0.arcsec, "04:14:08".hours, "-05 26 10".deg, 1280.0, 1024.0) + val pe = PolarErrorDetermination(initialFrame, position1, position2, position3, LNG, SLAT) - val pe = PolarErrorDetermination(position1, position2, position3, LNG, SLAT) + with(pe.destinationCoordinates(0.0, 0.0)) { + this[0] shouldBe ("04:14:08".hours plusOrMinus 1e-14) + this[1] shouldBe ("-05 26 10".deg plusOrMinus 1e-14) + } + } + "perfectly aligned" { + val position1 = Position("05:35:18".hours, "-05 23 26".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 10, 22, 58, 42.4979))) + val position2 = Position("04:54:45".hours, "-05 24 50".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 10, 22, 58, 58.1655))) + val position3 = Position("04:14:08".hours, "-05 26 10".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 10, 22, 59, 13.3739))) + val initialFrame = PlateSolution(true, 0.0, 1.0.arcsec, "04:14:08".hours, "-05 26 10".deg, 1280.0, 1024.0) + val pe = PolarErrorDetermination(initialFrame, position1, position2, position3, LNG, SLAT) val (az, alt) = pe.compute() // Calculated Error: Az: -00° 00' 04", Alt: -00° 00' 07", Tot: 00° 00' 08" @@ -48,21 +58,10 @@ class ThreePointPolarAlignmentTest : StringSpec() { } "bad southern polar aligned" { val position1 = Position("05:35:29".hours, "-05 23 44".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 11, 1, 2, 28.0693))) - position1.vector[0] shouldBe (0.260120895582042 plusOrMinus 1e-4) - position1.vector[1] shouldBe (0.452793316696993 plusOrMinus 1e-4) - position1.vector[2] shouldBe (0.852827844313337 plusOrMinus 1e-4) - val position2 = Position("04:54:48".hours, "-05 23 16".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 11, 1, 2, 43.0120))) - position2.vector[0] shouldBe (0.223679240068826 plusOrMinus 1e-4) - position2.vector[1] shouldBe (0.603179137475884 plusOrMinus 1e-4) - position2.vector[2] shouldBe (0.765599455117414 plusOrMinus 1e-4) - val position3 = Position("04:14:05".hours, "-05 22 47".deg, LNG, SLAT, UTC(TimeYMDHMS(2024, 2, 11, 1, 2, 57.8800))) - position3.vector[0] shouldBe (0.177343985686423 plusOrMinus 1e-4) - position3.vector[1] shouldBe (0.734426214459154 plusOrMinus 1e-4) - position3.vector[2] shouldBe (0.65510857592925 plusOrMinus 1e-4) - - val pe = PolarErrorDetermination(position1, position2, position3, LNG, SLAT) + val initialFrame = PlateSolution(true, 0.0, 1.0.arcsec, "04:14:05".hours, "-05 22 47".deg, 1280.0, 1024.0) + val pe = PolarErrorDetermination(initialFrame, position1, position2, position3, LNG, SLAT) val (az, alt) = pe.compute() // Calculated Error: Az: 00° 10' 10", Alt: 00° 04' 41", Tot: 00° 11' 11" @@ -71,21 +70,10 @@ class ThreePointPolarAlignmentTest : StringSpec() { } "bad northern polar aligned" { val position1 = Position("05:35:35".hours, "-05 32 31".deg, LNG, NLAT, UTC(TimeYMDHMS(2024, 2, 11, 1, 19, 31.1390))) - position1.vector[0] shouldBe (-0.420977957462894 plusOrMinus 1e-4) - position1.vector[1] shouldBe (0.517127315719859 plusOrMinus 1e-4) - position1.vector[2] shouldBe (0.745222717492391 plusOrMinus 1e-4) - val position2 = Position("04:54:49".hours, "-05 34 43".deg, LNG, NLAT, UTC(TimeYMDHMS(2024, 2, 11, 1, 19, 46.2383))) - position2.vector[0] shouldBe (-0.379893065189774 plusOrMinus 1e-4) - position2.vector[1] shouldBe (0.660293278184844 plusOrMinus 1e-4) - position2.vector[2] shouldBe (0.647837978050554 plusOrMinus 1e-4) - val position3 = Position("04:13:55".hours, "-05 36 32".deg, LNG, NLAT, UTC(TimeYMDHMS(2024, 2, 11, 1, 20, 1.6394))) - position3.vector[0] shouldBe (-0.329258727296886 plusOrMinus 1e-4) - position3.vector[1] shouldBe (0.782693400663722 plusOrMinus 1e-4) - position3.vector[2] shouldBe (0.528185318857211 plusOrMinus 1e-4) - - val pe = PolarErrorDetermination(position1, position2, position3, LNG, NLAT) + val initialFrame = PlateSolution(true, 0.0, 1.0.arcsec, "04:13:55".hours, "-05 36 32".deg, 1280.0, 1024.0) + val pe = PolarErrorDetermination(initialFrame, position1, position2, position3, LNG, NLAT) val (az, alt) = pe.compute() // Calculated Error: Az: -00° 09' 58", Alt: 00° 04' 51", Tot: 00° 11' 05" diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFilterWheelService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFilterWheelService.kt index 8a3e72f44..49ae78792 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFilterWheelService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaFilterWheelService.kt @@ -21,6 +21,7 @@ interface AlpacaFilterWheelService : AlpacaDeviceService { @GET("api/v1/filterwheel/{id}/position") fun position(@Path("id") id: Int): Call - @GET("api/v1/filterwheel/{id}/alignmentmode") + @FormUrlEncoded + @PUT("api/v1/filterwheel/{id}/position") fun position(@Path("id") id: Int, @Field("Position") position: Int): Call } diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaRotatorService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaRotatorService.kt new file mode 100644 index 000000000..da6167647 --- /dev/null +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaRotatorService.kt @@ -0,0 +1,48 @@ +package nebulosa.alpaca.api + +import retrofit2.Call +import retrofit2.http.* + +interface AlpacaRotatorService : AlpacaDeviceService { + + @GET("api/v1/rotator/{id}/connected") + override fun isConnected(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/rotator/{id}/connected") + override fun connect(@Path("id") id: Int, @Field("Connected") connected: Boolean): Call + + @GET("api/v1/rotator/{id}/canreverse") + fun canReverse(@Path("id") id: Int): Call + + @GET("api/v1/rotator/{id}/ismoving") + fun isMoving(@Path("id") id: Int): Call + + @GET("api/v1/rotator/{id}/reverse") + fun isReversed(@Path("id") id: Int): Call + + @GET("api/v1/rotator/{id}/position") + fun position(@Path("id") id: Int): Call + + @GET("api/v1/rotator/{id}/stepsize") + fun stepSize(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/rotator/{id}/reverse") + fun reverse(@Path("id") id: Int, @Field("Reverse") reverse: Boolean): Call + + @PUT("api/v1/rotator/{id}/halt") + fun halt(@Path("id") id: Int): Call + + @FormUrlEncoded + @PUT("api/v1/rotator/{id}/move") + fun move(@Path("id") id: Int, @Field("Position") position: Double): Call + + @FormUrlEncoded + @PUT("api/v1/rotator/{id}/moveabsolute") + fun moveTo(@Path("id") id: Int, @Field("Position") position: Double): Call + + @FormUrlEncoded + @PUT("api/v1/rotator/{id}/sync") + fun sync(@Path("id") id: Int, @Field("Position") position: Double): Call +} diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt index 94c180c0a..16e9b6bd0 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaService.kt @@ -24,4 +24,6 @@ class AlpacaService( val filterWheel by lazy { retrofit.create() } val focuser by lazy { retrofit.create() } + + val rotator by lazy { retrofit.create() } } diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt index 74e4ef4a4..d4d0f166b 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AlpacaTelescopeService.kt @@ -211,12 +211,11 @@ interface AlpacaTelescopeService : AlpacaGuideOutputService { @PUT("api/v1/telescope/{id}/utcDate") fun utcDate(@Path("id") id: Int, @Field("UTCDate") date: Instant): Call - @FormUrlEncoded @PUT("api/v1/telescope/{id}/abortslew") fun abortSlew(@Path("id") id: Int): Call @GET("api/v1/telescope/{id}/axisrates") - fun axisRates(@Path("id") id: Int): Call> + fun axisRates(@Path("id") id: Int, @Query("Axis") axis: AxisType): Call> @GET("api/v1/telescope/{id}/canmoveaxis") fun canMoveAxis(@Path("id") id: Int): Call @@ -224,7 +223,6 @@ interface AlpacaTelescopeService : AlpacaGuideOutputService { @GET("api/v1/telescope/{id}/destinationsideofpier") fun destinationSideOfPier(@Path("id") id: Int): Call - @FormUrlEncoded @PUT("api/v1/telescope/{id}/findhome") fun findHome(@Path("id") id: Int): Call @@ -232,7 +230,6 @@ interface AlpacaTelescopeService : AlpacaGuideOutputService { @PUT("api/v1/telescope/{id}/moveaxis") fun moveAxis(@Path("id") id: Int, @Field("Axis") axis: AxisType, @Field("Rate") rate: Double): Call - @FormUrlEncoded @PUT("api/v1/telescope/{id}/park") fun park(@Path("id") id: Int): Call @@ -244,7 +241,6 @@ interface AlpacaTelescopeService : AlpacaGuideOutputService { @Field("Duration") durationInMilliseconds: Long ): Call - @FormUrlEncoded @PUT("api/v1/telescope/{id}/setpark") fun setPark(@Path("id") id: Int): Call @@ -272,11 +268,9 @@ interface AlpacaTelescopeService : AlpacaGuideOutputService { @Field("Declination") declination: Double ): Call - @FormUrlEncoded @PUT("api/v1/telescope/{id}/slewtotarget") fun slewToTarget(@Path("id") id: Int): Call - @FormUrlEncoded @PUT("api/v1/telescope/{id}/slewtotargetasync") fun slewToTargetAsync(@Path("id") id: Int): Call @@ -292,11 +286,9 @@ interface AlpacaTelescopeService : AlpacaGuideOutputService { @Field("Declination") declination: Double ): Call - @FormUrlEncoded @PUT("api/v1/telescope/{id}/synctotarget") fun syncToTarget(@Path("id") id: Int): Call - @FormUrlEncoded @PUT("api/v1/telescope/{id}/unpark") fun unpark(@Path("id") id: Int): Call } diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AxisRate.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AxisRate.kt index 2fafac8d6..3a075660a 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AxisRate.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/AxisRate.kt @@ -1,8 +1,9 @@ package nebulosa.alpaca.api import com.fasterxml.jackson.annotation.JsonProperty +import java.math.BigDecimal data class AxisRate( - @field:JsonProperty("Maximum") val maximum: Double, - @field:JsonProperty("Minimum") val minimum: Double, + @field:JsonProperty("Maximum") val maximum: BigDecimal = BigDecimal.ZERO, + @field:JsonProperty("Minimum") val minimum: BigDecimal = BigDecimal.ZERO, ) diff --git a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DateTimeResponse.kt b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DateTimeResponse.kt index a43a09382..168e49293 100644 --- a/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DateTimeResponse.kt +++ b/nebulosa-alpaca-api/src/main/kotlin/nebulosa/alpaca/api/DateTimeResponse.kt @@ -1,12 +1,14 @@ package nebulosa.alpaca.api +import com.fasterxml.jackson.annotation.JsonFormat import com.fasterxml.jackson.annotation.JsonProperty -import java.time.Instant +import java.time.LocalDateTime data class DateTimeResponse( @field:JsonProperty("ClientTransactionID") override val clientTransactionID: Int = 0, @field:JsonProperty("ServerTransactionID") override val serverTransactionID: Int = 0, @field:JsonProperty("ErrorNumber") override val errorNumber: Int = 0, @field:JsonProperty("ErrorMessage") override val errorMessage: String = "", - @field:JsonProperty("Value") override val value: Instant = Instant.now(), -) : AlpacaResponse + @field:JsonProperty("Value") @field:JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSS]") + override val value: LocalDateTime = LocalDateTime.now(), +) : AlpacaResponse diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt index ebe79320c..89eaaf687 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/client/AlpacaClient.kt @@ -2,31 +2,12 @@ package nebulosa.alpaca.indi.client import nebulosa.alpaca.api.AlpacaService import nebulosa.alpaca.api.DeviceType -import nebulosa.alpaca.indi.device.ASCOMDevice import nebulosa.alpaca.indi.device.cameras.ASCOMCamera import nebulosa.alpaca.indi.device.focusers.ASCOMFocuser import nebulosa.alpaca.indi.device.mounts.ASCOMMount +import nebulosa.alpaca.indi.device.rotators.ASCOMRotator import nebulosa.alpaca.indi.device.wheels.ASCOMFilterWheel -import nebulosa.indi.device.DeviceEvent -import nebulosa.indi.device.DeviceEventHandler -import nebulosa.indi.device.INDIDeviceProvider -import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.camera.CameraAttached -import nebulosa.indi.device.camera.CameraDetached -import nebulosa.indi.device.filterwheel.FilterWheel -import nebulosa.indi.device.filterwheel.FilterWheelAttached -import nebulosa.indi.device.filterwheel.FilterWheelDetached -import nebulosa.indi.device.focuser.Focuser -import nebulosa.indi.device.focuser.FocuserAttached -import nebulosa.indi.device.focuser.FocuserDetached -import nebulosa.indi.device.gps.GPS -import nebulosa.indi.device.guide.GuideOutput -import nebulosa.indi.device.guide.GuideOutputAttached -import nebulosa.indi.device.guide.GuideOutputDetached -import nebulosa.indi.device.mount.Mount -import nebulosa.indi.device.mount.MountAttached -import nebulosa.indi.device.mount.MountDetached -import nebulosa.indi.device.thermometer.Thermometer +import nebulosa.indi.device.AbstractINDIDeviceProvider import nebulosa.indi.protocol.INDIProtocol import nebulosa.log.loggerFor import okhttp3.OkHttpClient @@ -35,91 +16,13 @@ import java.util.* data class AlpacaClient( val host: String, val port: Int, private val httpClient: OkHttpClient? = null, -) : INDIDeviceProvider { +) : AbstractINDIDeviceProvider() { private val service = AlpacaService("http://$host:$port/", httpClient) - private val handlers = LinkedHashSet() - private val cameras = HashMap() - private val mounts = HashMap() - private val wheels = HashMap() - private val focusers = HashMap() - private val guideOutputs = HashMap() override val id = UUID.randomUUID().toString() - override fun registerDeviceEventHandler(handler: DeviceEventHandler) { - handlers.add(handler) - } - - override fun unregisterDeviceEventHandler(handler: DeviceEventHandler) { - handlers.remove(handler) - } - - override fun sendMessageToServer(message: INDIProtocol) {} - - internal fun fireOnEventReceived(event: DeviceEvent<*>) { - handlers.forEach { it.onEventReceived(event) } - } - - internal fun fireOnConnectionClosed() { - handlers.forEach { it.onConnectionClosed() } - } - - override fun cameras(): List { - return synchronized(cameras) { cameras.values.toList() } - } - - override fun camera(name: String): Camera? { - return synchronized(cameras) { cameras[name] ?: cameras.values.find { it.name == name } } - } - - override fun mounts(): List { - return emptyList() - } - - override fun mount(name: String): Mount? { - return null - } - - override fun focusers(): List { - return emptyList() - } - - override fun focuser(name: String): Focuser? { - return null - } - - override fun wheels(): List { - return emptyList() - } - - override fun wheel(name: String): FilterWheel? { - return null - } - - override fun gps(): List { - return emptyList() - } - - override fun gps(name: String): GPS? { - return null - } - - override fun guideOutputs(): List { - return emptyList() - } - - override fun guideOutput(name: String): GuideOutput? { - return null - } - - override fun thermometers(): List { - return emptyList() - } - - override fun thermometer(name: String): Thermometer? { - return null - } + override fun sendMessageToServer(message: INDIProtocol) = Unit fun discovery() { val response = service.management.configuredDevices().execute() @@ -130,50 +33,40 @@ data class AlpacaClient( for (device in body.value) { when (device.type) { DeviceType.CAMERA -> { - if (device.uid in cameras) continue - - synchronized(cameras) { - with(ASCOMCamera(device, service.camera, this)) { - cameras[device.uid] = this - LOG.info("camera attached: {}", device.name) - fireOnEventReceived(CameraAttached(this)) + with(ASCOMCamera(device, service.camera, this)) { + if (registerCamera(this)) { + initialize() } } } DeviceType.TELESCOPE -> { - if (device.uid in mounts) continue - - synchronized(mounts) { - with(ASCOMMount(device, service.telescope, this)) { - mounts[device.uid] = this - LOG.info("mount attached: {}", device.name) - fireOnEventReceived(MountAttached(this)) + with(ASCOMMount(device, service.telescope, this)) { + if (registerMount(this)) { + initialize() } } } DeviceType.FILTER_WHEEL -> { - if (device.uid in wheels) continue - - synchronized(wheels) { - with(ASCOMFilterWheel(device, service.filterWheel, this)) { - wheels[device.uid] = this - LOG.info("filter wheel attached: {}", device.name) - fireOnEventReceived(FilterWheelAttached(this)) + with(ASCOMFilterWheel(device, service.filterWheel, this)) { + if (registerFilterWheel(this)) { + initialize() } } } DeviceType.FOCUSER -> { - if (device.uid in focusers) continue - - synchronized(focusers) { - with(ASCOMFocuser(device, service.focuser, this)) { - focusers[device.uid] = this - LOG.info("focuser attached: {}", device.name) - fireOnEventReceived(FocuserAttached(this)) + with(ASCOMFocuser(device, service.focuser, this)) { + if (registerFocuser(this)) { + initialize() + } + } + } + DeviceType.ROTATOR -> { + with(ASCOMRotator(device, service.rotator, this)) { + if (registerRotator(this)) { + initialize() } } } - DeviceType.ROTATOR -> Unit DeviceType.DOME -> Unit DeviceType.SWITCH -> Unit DeviceType.COVER_CALIBRATOR -> Unit @@ -189,60 +82,6 @@ data class AlpacaClient( } } - internal fun registerGuideOutput(device: GuideOutput) { - if (device is ASCOMDevice) { - guideOutputs[device.id] = device - fireOnEventReceived(GuideOutputAttached(device)) - } - } - - internal fun unregisterGuideOutput(device: GuideOutput) { - if (device.name in guideOutputs) { - guideOutputs.remove(device.name) - fireOnEventReceived(GuideOutputDetached(device)) - } - } - - override fun close() { - for ((_, device) in cameras) { - device.close() - LOG.info("camera detached: {}", device.name) - fireOnEventReceived(CameraDetached(device)) - } - - for ((_, device) in mounts) { - device.close() - LOG.info("mount detached: {}", device.name) - fireOnEventReceived(MountDetached(device)) - } - - for ((_, device) in wheels) { - device.close() - LOG.info("filter wheel detached: {}", device.name) - fireOnEventReceived(FilterWheelDetached(device)) - } - - for ((_, device) in focusers) { - device.close() - LOG.info("focuser detached: {}", device.name) - fireOnEventReceived(FocuserDetached(device)) - } - - // for ((_, device) in gps) { - // device.close() - // LOG.info("gps detached: {}", device.name) - // fireOnEventReceived(GPSDetached(device)) - // } - - cameras.clear() - mounts.clear() - wheels.clear() - focusers.clear() - // gps.clear() - - handlers.clear() - } - companion object { @JvmStatic private val LOG = loggerFor() diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt index 4c4111955..6a58bb809 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/ASCOMDevice.kt @@ -4,15 +4,19 @@ import nebulosa.alpaca.api.AlpacaDeviceService import nebulosa.alpaca.api.AlpacaResponse import nebulosa.alpaca.api.ConfiguredDevice import nebulosa.alpaca.indi.client.AlpacaClient +import nebulosa.common.Resettable import nebulosa.common.time.Stopwatch import nebulosa.indi.device.* +import nebulosa.log.debug import nebulosa.log.loggerFor import retrofit2.Call import retrofit2.HttpException import java.time.LocalDateTime import java.util.* +import java.util.concurrent.atomic.AtomicReference +import kotlin.system.measureTimeMillis -abstract class ASCOMDevice : Device { +abstract class ASCOMDevice : Device, Resettable { protected abstract val device: ConfiguredDevice protected abstract val service: AlpacaDeviceService @@ -30,7 +34,18 @@ abstract class ASCOMDevice : Device { override val properties = emptyMap>() override val messages = LinkedList() - @Volatile private var refresher: Refresher? = null + private val refresher = AtomicReference() + + internal open fun initialize() { + refresh(0L) + + if (refresher.get() == null) { + with(Refresher()) { + refresher.set(this) + start() + } + } + } override fun connect() { service.connect(device.number, true).doRequest() @@ -41,16 +56,15 @@ abstract class ASCOMDevice : Device { } open fun refresh(elapsedTimeInSeconds: Long) { - service.isConnected(device.number).doRequest { processConnected(it.value) } + processConnected() } - open fun reset() { + override fun reset() { connected = false } override fun close() { - refresher?.interrupt() - refresher = null + refresher.getAndSet(null)?.interrupt() } protected abstract fun onConnected() @@ -71,23 +85,29 @@ abstract class ASCOMDevice : Device { protected fun > Call.doRequest(): T? { try { - val response = execute().body() + val request = request() + val response = execute() + val body = response.body() - return if (response == null) { - LOG.warn("response has no body. device={}", name) + return if (body == null) { + LOG.debug { "response has no body. device=%s, request=%s %s, response=%s".format(name, request.method, request.url, response) } null - } else if (response.errorNumber != 0) { - val message = response.errorMessage + } else if (body.errorNumber != 0) { + val message = body.errorMessage if (message.isNotEmpty()) { addMessageAndFireEvent("[%s]: %s".format(LocalDateTime.now(), message)) } - // LOG.warn("unsuccessful response. device={}, code={}, message={}", name, response.errorNumber, response.errorMessage) + LOG.debug { + "unsuccessful response. device=%s, request=%s %s, errorNumber=%s, message=%s".format( + name, request.method, request.url, body.errorNumber, body.errorMessage + ) + } null } else { - response + body } } catch (e: HttpException) { LOG.error("unexpected response. device=$name", e) @@ -103,26 +123,20 @@ abstract class ASCOMDevice : Device { return doRequest()?.also(action) != null } + private fun processConnected() { + service.isConnected(device.number).doRequest { processConnected(it.value) } + } + protected fun processConnected(value: Boolean) { if (connected != value) { connected = value if (value) { sender.fireOnEventReceived(DeviceConnected(this)) - onConnected() - - if (refresher == null) { - refresher = Refresher() - refresher!!.start() - } } else { sender.fireOnEventReceived(DeviceDisconnected(this)) - onDisconnected() - - refresher?.interrupt() - refresher = null } } } @@ -139,10 +153,11 @@ abstract class ASCOMDevice : Device { stopwatch.start() while (true) { - val startTime = System.currentTimeMillis() - refresh(stopwatch.elapsedSeconds) - val endTime = System.currentTimeMillis() - val delayTime = 2000L - (endTime - startTime) + val elapsedTime = measureTimeMillis { + refresh(stopwatch.elapsedSeconds) + } + + val delayTime = 2000L - elapsedTime if (delayTime > 1L) { sleep(delayTime) diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt index a8a4df2d1..14c3fb6a6 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/cameras/ASCOMCamera.kt @@ -1,9 +1,6 @@ package nebulosa.alpaca.indi.device.cameras -import nebulosa.alpaca.api.AlpacaCameraService -import nebulosa.alpaca.api.CameraState -import nebulosa.alpaca.api.ConfiguredDevice -import nebulosa.alpaca.api.PulseGuideDirection +import nebulosa.alpaca.api.* import nebulosa.alpaca.indi.client.AlpacaClient import nebulosa.alpaca.indi.device.ASCOMDevice import nebulosa.common.concurrency.latch.CountUpDownLatch @@ -22,22 +19,22 @@ import nebulosa.indi.device.guide.GuideOutputPulsingChanged import nebulosa.indi.device.mount.Mount import nebulosa.indi.protocol.INDIProtocol import nebulosa.indi.protocol.PropertyState -import nebulosa.io.readDoubleLe -import nebulosa.io.readFloatLe import nebulosa.log.loggerFor -import nebulosa.math.formatHMS -import nebulosa.math.formatSignedDMS +import nebulosa.math.AngleFormatter +import nebulosa.math.format import nebulosa.math.normalized -import nebulosa.math.toDegrees import nebulosa.nova.position.Geoid import nebulosa.nova.position.ICRF import nebulosa.time.CurrentTime import okio.buffer import okio.source import java.nio.ByteBuffer +import java.nio.ByteOrder import java.time.Duration -import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneOffset import java.time.format.DateTimeFormatter +import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.max import kotlin.math.min @@ -103,13 +100,14 @@ data class ASCOMCamera( @Volatile private var cameraState = CameraState.IDLE @Volatile private var frameType = FrameType.LIGHT @Volatile private var fitsKeywords: Array = emptyArray() + @Volatile private var canDebayer = false private val imageReadyWaiter = ImageReadyWaiter() override val snoopedDevices = ArrayList(4) - init { - refresh(0L) + override fun initialize() { + super.initialize() imageReadyWaiter.start() } @@ -158,10 +156,12 @@ data class ASCOMCamera( } override fun startCapture(exposureTime: Duration) { - this.exposureTime = exposureTime + if (!exposuring) { + this.exposureTime = exposureTime - service.startExposure(device.number, exposureTime.toNanos() / NANO_TO_SECONDS, frameType == FrameType.DARK).doRequest { - imageReadyWaiter.captureStarted() + service.startExposure(device.number, exposureTime.toNanos() / NANO_TO_SECONDS, frameType == FrameType.LIGHT).doRequest { + imageReadyWaiter.captureStarted(exposureTime) + } } } @@ -200,6 +200,8 @@ data class ASCOMCamera( } override fun snoop(devices: Iterable) { + snoopedDevices.clear() + for (device in devices) { device?.also(snoopedDevices::add) } @@ -278,6 +280,16 @@ data class ASCOMCamera( } override fun close() { + if (hasThermometer) { + hasThermometer = false + sender.unregisterThermometer(this) + } + + if (canPulseGuide) { + canPulseGuide = false + sender.unregisterGuideOutput(this) + } + super.close() reset() imageReadyWaiter.interrupt() @@ -288,12 +300,18 @@ data class ASCOMCamera( super.refresh(elapsedTimeInSeconds) if (connected) { - service.cameraState(device.number).doRequest { processCameraState(it.value) } - + processCameraState() processBin() processGain() processOffset() processCooler() + processTemperature(false) + } + } + + private fun processCameraState() { + if (!service.cameraState(device.number).doRequest { processCameraState(it.value) }) { + sender.fireOnEventReceived(CameraExposureFailed(this)) } } @@ -306,55 +324,45 @@ data class ASCOMCamera( when (value) { CameraState.IDLE -> { - if (exposuring) { - exposuring = false - exposureState = PropertyState.IDLE - } + exposuring = false + exposureState = PropertyState.IDLE } CameraState.WAITING -> { - if (exposuring) { - exposuring = false - exposureState = PropertyState.BUSY - } + exposuring = false + exposureState = PropertyState.BUSY } CameraState.EXPOSURING -> { - if (!exposuring) { - exposuring = true - exposureState = PropertyState.BUSY - } + exposuring = true + exposureState = PropertyState.BUSY } CameraState.READING -> { - if (exposuring) { - exposuring = false - exposureState = PropertyState.OK - } + exposuring = false + exposureState = PropertyState.OK } CameraState.DOWNLOAD -> { - if (exposuring) { - exposuring = false - exposureState = PropertyState.OK - } + exposuring = false + exposureState = PropertyState.OK } CameraState.ERROR -> { - if (exposuring) { - exposuring = false - exposureState = PropertyState.ALERT - } + exposuring = false + exposureState = PropertyState.ALERT } } if (prevExposuring != exposuring) sender.fireOnEventReceived(CameraExposuringChanged(this)) - if (prevExposureState != exposureState) sender.fireOnEventReceived(CameraExposureStateChanged(this, prevExposureState)) + if (prevExposureState != exposureState) sender.fireOnEventReceived(CameraExposureStateChanged(this)) if (exposuring) { service.percentCompleted(device.number).doRequest { + val exposureTimeInNanoseconds = imageReadyWaiter.exposureTime.toNanos() + val progressedExposureTime = (exposureTimeInNanoseconds * it.value) / 100 + exposureTime = Duration.ofNanos(exposureTimeInNanoseconds - progressedExposureTime) + sender.fireOnEventReceived(CameraExposureProgressChanged(this)) } } - if (exposureState == PropertyState.IDLE && (prevExposureState == PropertyState.BUSY || exposuring)) { - sender.fireOnEventReceived(CameraExposureAborted(this)) - } else if (exposureState == PropertyState.OK && prevExposureState == PropertyState.BUSY) { + if (exposureState == PropertyState.OK && prevExposureState == PropertyState.BUSY) { sender.fireOnEventReceived(CameraExposureFinished(this)) } else if (exposureState == PropertyState.ALERT && prevExposureState != PropertyState.ALERT) { sender.fireOnEventReceived(CameraExposureFailed(this)) @@ -410,11 +418,13 @@ data class ASCOMCamera( } private fun processOffset() { - service.offset(device.number).doRequest { - if (it.value != offset) { - offset = it.value + if (offsetMax > 0) { + service.offset(device.number).doRequest { + if (it.value != offset) { + offset = it.value - sender.fireOnEventReceived(CameraOffsetChanged(this)) + sender.fireOnEventReceived(CameraOffsetChanged(this)) + } } } } @@ -551,7 +561,6 @@ data class ASCOMCamera( if (it.value) { canPulseGuide = true sender.registerGuideOutput(this) - LOG.info("guide output attached: {}", name) } } @@ -564,9 +573,33 @@ data class ASCOMCamera( sender.fireOnEventReceived(CameraCanSetTemperatureChanged(this)) } } + + service.sensorType(device.number).doRequest { + if (it.value == SensorType.RGGB) { + canDebayer = true + } + } + + processTemperature(true) + } + + private fun processTemperature(init: Boolean) { + if (hasThermometer || init) { + service.ccdTemperature(device.number).doRequest { + if (!hasThermometer) { + hasThermometer = true + sender.registerThermometer(this) + } + + if (it.value != temperature) { + temperature = it.value + sender.fireOnEventReceived(CameraTemperatureChanged(this)) + } + } + } } - private fun readImage() { + private fun readImage(exposureTime: Duration) { service.imageArray(device.number).execute().body()?.use { body -> val stream = body.byteStream() val metadata = ImageMetadata.from(stream.readNBytes(44)) @@ -574,45 +607,47 @@ data class ASCOMCamera( if (metadata.errorNumber != 0) { LOG.error("failed to read image. device={}, error={}", name, metadata.errorNumber) return + } else { + LOG.info("image read. metadata={}", metadata) } val width = metadata.dimension1 val height = metadata.dimension2 val numberOfChannels = max(1, metadata.dimension3) - val source = stream.source().buffer() val data = FloatImageData(width, height, numberOfChannels) - val channels = arrayOf(data.red, data.green, data.blue) - - for (x in 0 until width) { - for (y in 0 until height) { - val idx = y * width + x - - for (p in 0 until numberOfChannels) { - channels[p][idx] = when (metadata.imageElementType.bitpix) { - Bitpix.BYTE -> (source.readByte().toInt() and 0xFF) / 255f - Bitpix.SHORT -> (source.readShortLe().toInt() + 32768) / 65535f - Bitpix.INTEGER -> ((source.readIntLe().toLong() + 2147483648L) / 4294967295.0).toFloat() - Bitpix.FLOAT -> source.readFloatLe() - Bitpix.DOUBLE -> source.readDoubleLe().toFloat() - Bitpix.LONG -> return + + stream.source().buffer().use { source -> + val channels = arrayOf(data.red, data.green, data.blue) + + for (x in 0 until width) { + for (y in 0 until height) { + val idx = y * width + x + + for (p in 0 until numberOfChannels) { + channels[p][idx] = when (metadata.transmissionElementType.bitpix) { + Bitpix.BYTE -> (source.readByte().toLong() and 0xFF) / 255f + Bitpix.SHORT -> (source.readShortLe().toLong() and 0xFFFF) / 65535f + Bitpix.INTEGER -> ((source.readIntLe().toLong() and 0xFFFFFFFF) / 4294967295.0).toFloat() + else -> return LOG.warn("invalid transmission element type: ${metadata.transmissionElementType}") + } } } } } - source.close() - val header = FitsHeader() header.add(FitsKeyword.SIMPLE, true) - header.add(FitsKeyword.BITPIX, -32) + header.add(Bitpix.FLOAT) header.add(FitsKeyword.NAXIS, if (numberOfChannels == 3) 3 else 2) header.add(FitsKeyword.NAXIS1, width) header.add(FitsKeyword.NAXIS2, height) if (numberOfChannels == 3) header.add(FitsKeyword.NAXIS3, numberOfChannels) header.add(FitsKeyword.EXTEND, true) header.add(FitsKeyword.INSTRUME, name) - header.add(FitsKeyword.EXPTIME, 0.0) // TODO - header.add(FitsKeyword.CCD_TEMP, temperature) + val exposureTimeInSeconds = exposureTime.toNanos() / NANO_TO_SECONDS + header.add(FitsKeyword.EXPTIME, exposureTimeInSeconds) + header.add(FitsKeyword.EXPOSURE, exposureTimeInSeconds) + if (hasThermometer) header.add(FitsKeyword.CCD_TEMP, temperature) header.add(FitsKeyword.PIXSIZEn.n(1), pixelSizeX) header.add(FitsKeyword.PIXSIZEn.n(2), pixelSizeY) header.add(FitsKeyword.XBINNING, binX) @@ -621,26 +656,27 @@ data class ASCOMCamera( header.add(FitsKeyword.YPIXSZ, pixelSizeY * binY) header.add("FRAME", frameType.description, "Frame Type") header.add(FitsKeyword.IMAGETYP, "${frameType.description} Frame") + header.add(FitsKeyword.DATE_OBS, LocalDateTime.now(ZoneOffset.UTC).format(DATE_OBS_FORMAT)) + header.add(FitsKeyword.COMMENT, "Generated by Nebulosa via ASCOM") + header.add(FitsKeyword.GAIN, gain) + header.add("OFFSET", offset, "Offset") + if (canDebayer) header.add(cfaType) val mount = snoopedDevices.firstOrNull { it is Mount } as? Mount mount?.also { header.add(FitsKeyword.TELESCOP, it.name) - header.add(FitsKeyword.SITELAT, it.latitude.toDegrees) - header.add(FitsKeyword.SITELONG, it.longitude.toDegrees) + header.add(FitsKeyword.SITELONG, it.longitude.format(DEC_FORMAT)) + header.add(FitsKeyword.SITELAT, it.latitude.format(DEC_FORMAT)) val center = Geoid.IERS2010.lonLat(it.longitude, it.latitude, it.elevation) val icrf = ICRF.equatorial(it.rightAscension, it.declination, epoch = CurrentTime, center = center) val raDec = icrf.equatorial() - header.add(FitsKeyword.OBJCTRA, raDec.longitude.normalized.formatHMS()) - header.add(FitsKeyword.OBJCTDEC, raDec.longitude.formatSignedDMS()) - header.add(FitsKeyword.RA, raDec.longitude.normalized.toDegrees) - header.add(FitsKeyword.DEC, raDec.longitude.toDegrees) - header.add(FitsKeyword.PIERSIDE, it.pierSide.name) + header.add(FitsKeyword.OBJCTRA, raDec.longitude.normalized.format(RA_FORMAT)) + header.add(FitsKeyword.OBJCTDEC, raDec.latitude.format(DEC_FORMAT)) + header.add(FitsKeyword.RA, raDec.longitude.normalized.format(RA_FORMAT)) + header.add(FitsKeyword.DEC, raDec.latitude.format(DEC_FORMAT)) + // header.add(FitsKeyword.PIERSIDE, it.pierSide.name) header.add(FitsKeyword.EQUINOX, 2000) - header.add(FitsKeyword.DATE_OBS, LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) - header.add(FitsKeyword.COMMENT, "Generated by Nebulosa via ASCOM") - header.add(FitsKeyword.GAIN, gain) - header.add("OFFSET", offset, "Offset") } fitsKeywords.forEach(header::add) @@ -678,7 +714,7 @@ data class ASCOMCamera( @JvmField val serverTransactionID: Int, // Bytes 12..15 - Device's transaction ID @JvmField val dataStart: Int, // Bytes 16..19 - Offset of the start of the data bytes @JvmField val imageElementType: ImageArrayElementType, // Bytes 20..23 - Element type of the source image array - @JvmField val transmissionElementType: Int, // Bytes 24..27 - Element type as sent over the network + @JvmField val transmissionElementType: ImageArrayElementType, // Bytes 24..27 - Element type as sent over the network @JvmField val rank: Int, // Bytes 28..31 - Image array rank (2 or 3) @JvmField val dimension1: Int, // Bytes 32..35 - Length of image array first dimension @JvmField val dimension2: Int, // Bytes 36..39 - Length of image array second dimension @@ -690,28 +726,34 @@ data class ASCOMCamera( @JvmStatic fun from(data: ByteBuffer) = ImageMetadata( data.getInt(), data.getInt(), data.getInt(), data.getInt(), data.getInt(), - ImageArrayElementType.entries[data.getInt()], data.getInt(), data.getInt(), - data.getInt(), data.getInt(), data.getInt() + ImageArrayElementType.entries[data.getInt()], ImageArrayElementType.entries[data.getInt()], + data.getInt(), data.getInt(), data.getInt(), data.getInt() ) @JvmStatic - fun from(data: ByteArray) = from(ByteBuffer.wrap(data, 0, 44)) + fun from(data: ByteArray) = from(ByteBuffer.wrap(data, 0, 44).order(ByteOrder.LITTLE_ENDIAN)) } } private inner class ImageReadyWaiter : Thread("$name ASCOM Image Ready Waiter") { private val latch = CountUpDownLatch(1) + private val aborted = AtomicBoolean() + + @Volatile @JvmField var exposureTime: Duration = Duration.ZERO init { isDaemon = true } - fun captureStarted() { + fun captureStarted(exposureTime: Duration) { + this.exposureTime = exposureTime + aborted.set(false) latch.reset() } fun captureAborted() { + aborted.set(true) latch.countUp() } @@ -722,14 +764,23 @@ data class ASCOMCamera( while (latch.get()) { val startTime = System.currentTimeMillis() + processCameraState() + service.isImageReady(device.number).doRequest { - if (it.value) { + if (it.value && !aborted.get()) { latch.countUp() - readImage() + + try { + readImage(exposureTime) + } catch (e: Throwable) { + LOG.error("failed to read image", e) + } } } - if (!latch.get()) { + if (aborted.get()) { + break + } else if (!latch.get()) { val endTime = System.currentTimeMillis() val delayTime = 1000L - (endTime - startTime) @@ -738,6 +789,14 @@ data class ASCOMCamera( } } } + + if (aborted.get()) { + sender.fireOnEventReceived(CameraExposureAborted(this@ASCOMCamera)) + } else { + sender.fireOnEventReceived(CameraExposureFinished(this@ASCOMCamera)) + } + + processCameraState(CameraState.IDLE) } } } @@ -745,5 +804,8 @@ data class ASCOMCamera( companion object { @JvmStatic private val LOG = loggerFor() + @JvmStatic private val DATE_OBS_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS") + @JvmStatic private val RA_FORMAT = AngleFormatter.HMS.newBuilder().secondsDecimalPlaces(3).separators(" ").build() + @JvmStatic private val DEC_FORMAT = AngleFormatter.SIGNED_DMS.newBuilder().secondsDecimalPlaces(3).separators(" ").build() } } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/focusers/ASCOMFocuser.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/focusers/ASCOMFocuser.kt index 95e8e1253..c07f7990e 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/focusers/ASCOMFocuser.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/focusers/ASCOMFocuser.kt @@ -5,13 +5,7 @@ import nebulosa.alpaca.api.ConfiguredDevice import nebulosa.alpaca.indi.client.AlpacaClient import nebulosa.alpaca.indi.device.ASCOMDevice import nebulosa.indi.device.Device -import nebulosa.indi.device.focuser.Focuser -import nebulosa.indi.device.focuser.FocuserCanAbsoluteMoveChanged -import nebulosa.indi.device.focuser.FocuserMovingChanged -import nebulosa.indi.device.focuser.FocuserPositionChanged -import nebulosa.indi.device.thermometer.ThermometerAttached -import nebulosa.indi.device.thermometer.ThermometerDetached -import nebulosa.indi.device.thermometer.ThermometerTemperatureChanged +import nebulosa.indi.device.focuser.* import nebulosa.indi.protocol.INDIProtocol @Suppress("RedundantModalityModifier") @@ -24,8 +18,8 @@ data class ASCOMFocuser( @Volatile final override var moving = false @Volatile final override var position = 0 @Volatile final override var canAbsoluteMove = false - @Volatile final override var canRelativeMove = false - @Volatile final override var canAbort = false + @Volatile final override var canRelativeMove = true + @Volatile final override var canAbort = true @Volatile final override var canReverse = false @Volatile final override var reversed = false @Volatile final override var canSync = false @@ -54,32 +48,27 @@ data class ASCOMFocuser( } override fun moveFocusTo(steps: Int) { + service.move(device.number, steps).doRequest() } override fun abortFocus() { service.halt(device.number).doRequest() } - override fun reverseFocus(enable: Boolean) { - } + override fun reverseFocus(enable: Boolean) = Unit - override fun syncFocusTo(steps: Int) { - } + override fun syncFocusTo(steps: Int) = Unit - override fun snoop(devices: Iterable) { - } + override fun snoop(devices: Iterable) = Unit - override fun handleMessage(message: INDIProtocol) { - } + override fun handleMessage(message: INDIProtocol) = Unit override fun onConnected() { processCapabilities() processPosition() - processTemperature() } - override fun onDisconnected() { - } + override fun onDisconnected() = Unit override fun reset() { super.reset() @@ -99,20 +88,22 @@ data class ASCOMFocuser( } override fun close() { - super.close() - if (hasThermometer) { hasThermometer = false - sender.fireOnEventReceived(ThermometerDetached(this)) + sender.unregisterThermometer(this) } + + super.close() } override fun refresh(elapsedTimeInSeconds: Long) { super.refresh(elapsedTimeInSeconds) - processMoving() - processPosition() - processTemperature() + if (connected) { + processMoving() + processPosition() + processTemperature(false) + } } private fun processCapabilities() { @@ -123,10 +114,12 @@ data class ASCOMFocuser( } } - service.temperature(device.number).doRequest { - hasThermometer = true - sender.fireOnEventReceived(ThermometerAttached(this)) + service.maxStep(device.number).doRequest { + maxPosition = it.value + sender.fireOnEventReceived(FocuserMaxPositionChanged(this)) } + + processTemperature(true) } private fun processMoving() { @@ -149,13 +142,17 @@ data class ASCOMFocuser( } } - private fun processTemperature() { - if (hasThermometer) { + private fun processTemperature(init: Boolean) { + if (hasThermometer || init) { service.temperature(device.number).doRequest { + if (!hasThermometer) { + hasThermometer = true + sender.registerThermometer(this) + } + if (it.value != temperature) { temperature = it.value - - sender.fireOnEventReceived(ThermometerTemperatureChanged(this)) + sender.fireOnEventReceived(FocuserTemperatureChanged(this)) } } } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt index dc7b6f38a..dc79d320a 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/mounts/ASCOMMount.kt @@ -3,6 +3,7 @@ package nebulosa.alpaca.indi.device.mounts import nebulosa.alpaca.api.* import nebulosa.alpaca.indi.client.AlpacaClient import nebulosa.alpaca.indi.device.ASCOMDevice +import nebulosa.constants.DEG2RAD import nebulosa.indi.device.Device import nebulosa.indi.device.guide.GuideOutputPulsingChanged import nebulosa.indi.device.mount.* @@ -12,9 +13,11 @@ import nebulosa.log.loggerFor import nebulosa.math.* import nebulosa.nova.position.ICRF import nebulosa.time.CurrentTime +import java.math.BigDecimal import java.time.Duration import java.time.OffsetDateTime import java.time.ZoneOffset +import kotlin.math.abs @Suppress("RedundantModalityModifier") data class ASCOMMount( @@ -54,8 +57,7 @@ data class ASCOMMount( override val snoopedDevices = emptyList() - private val axisRates = HashMap(4) - @Volatile private var axisRate: AxisRate? = null + private val axisRates = HashMap>(4) @Volatile private var equatorialSystem = EquatorialCoordinateType.J2000 override fun park() { @@ -130,7 +132,7 @@ data class ASCOMMount( } override fun slewTo(ra: Angle, dec: Angle) { - service.tracking(device.number, true) + tracking(true) if (equatorialSystem != EquatorialCoordinateType.J2000) { service.slewToCoordinates(device.number, ra.toHours, dec.toDegrees).doRequest() @@ -143,7 +145,7 @@ data class ASCOMMount( } override fun slewToJ2000(ra: Angle, dec: Angle) { - service.tracking(device.number, true) + tracking(true) if (equatorialSystem == EquatorialCoordinateType.J2000) { service.slewToCoordinates(device.number, ra.toHours, dec.toDegrees).doRequest() @@ -177,22 +179,22 @@ data class ASCOMMount( override fun trackMode(mode: TrackMode) { if (mode != TrackMode.CUSTOM) { - service.trackingRate(device.number, DriveRate.entries[mode.ordinal]) + service.trackingRate(device.number, DriveRate.entries[mode.ordinal]).doRequest() } } override fun slewRate(rate: SlewRate) { - axisRate = axisRates[rate.name]?.takeIf { it !== axisRate } ?: return + slewRate = slewRates.firstOrNull { it.name == rate.name } ?: return sender.fireOnEventReceived(MountSlewRateChanged(this)) } private fun moveAxis(axisType: AxisType, negative: Boolean, enabled: Boolean) { - val rate = axisRate?.maximum ?: return - - service.moveAxis(device.number, axisType, 0.0).doRequest() + val rate = slewRate?.name?.let { axisRates[it] }?.second ?: return LOG.warn("axisRate is null") if (enabled) { - service.moveAxis(device.number, axisType, if (negative) -rate else rate).doRequest() + service.moveAxis(device.number, axisType, if (negative) -(rate.toDouble()) else rate.toDouble()).doRequest() + } else { + service.moveAxis(device.number, axisType, 0.0).doRequest() } } @@ -219,12 +221,12 @@ data class ASCOMMount( } override fun dateTime(dateTime: OffsetDateTime) { - service.utcDate(device.number, dateTime.toInstant()) + service.utcDate(device.number, dateTime.toInstant()).doRequest() } - override fun snoop(devices: Iterable) {} + override fun snoop(devices: Iterable) = Unit - override fun handleMessage(message: INDIProtocol) {} + override fun handleMessage(message: INDIProtocol) = Unit override fun onConnected() { processCapabilities() @@ -235,7 +237,7 @@ data class ASCOMMount( equatorialSystem = service.equatorialSystem(device.number).doRequest()?.value ?: equatorialSystem } - override fun onDisconnected() {} + override fun onDisconnected() = Unit override fun reset() { super.reset() @@ -270,7 +272,6 @@ data class ASCOMMount( dateTime = OffsetDateTime.now()!! axisRates.clear() - axisRate = null } override fun close() { @@ -285,12 +286,14 @@ data class ASCOMMount( override fun refresh(elapsedTimeInSeconds: Long) { super.refresh(elapsedTimeInSeconds) - processTracking() - processSlewing() - processParked() - processEquatorialCoordinates() - processSiteCoordinates() - processTrackMode() + if (connected) { + processTracking() + processSlewing() + processParked() + processEquatorialCoordinates() + processSiteCoordinates() + processTrackMode() + } } private fun processCapabilities() { @@ -312,7 +315,6 @@ data class ASCOMMount( if (it.value) { canPulseGuide = true sender.registerGuideOutput(this) - LOG.info("guide output attached: {}", name) } } @@ -329,24 +331,44 @@ data class ASCOMMount( processTrackMode() } - service.axisRates(device.number).doRequest { - val rates = ArrayList(it.value.size) + axisRates.clear() - axisRates.clear() + val rates = sortedMapOf>() - for (i in it.value.indices) { - val rate = it.value[i] - val name = "RATE_$i" - axisRates[name] = rate - rates.add(SlewRate(name, "%f.1f deg/s".format(rate.maximum))) - } + fun Array.populateWithNewRates() { + for (rate in this) { + var min = rate.minimum - axisRate = it.value.firstOrNull() + while (min <= rate.maximum) { + if (min > BigDecimal.ZERO && min !in rates) { + val name = "RATE_${axisRates.size}" + axisRates[name] = rate to min + rates[min] = SlewRate(name, "%.1f °/s".format(min)) to rate + } - if (axisRate != null) { - sender.fireOnEventReceived(MountSlewRateChanged(this)) + min += SLEW_RATE_INCREMENT + } } } + + service.axisRates(device.number, AxisType.PRIMARY).doRequest { + it.value.populateWithNewRates() + } + + service.axisRates(device.number, AxisType.SECONDARY).doRequest { + it.value.populateWithNewRates() + } + + slewRates = rates.values.map { it.first } + slewRate = slewRates.firstOrNull() + + if (slewRates.isNotEmpty()) { + sender.fireOnEventReceived(MountSlewRatesChanged(this)) + } + + if (slewRate != null) { + sender.fireOnEventReceived(MountSlewRateChanged(this)) + } } private fun processTracking() { @@ -402,7 +424,7 @@ data class ASCOMMount( private fun processDateTime() { service.utcDate(device.number).doRequest { - dateTime = it.value.atOffset(ZoneOffset.systemDefault().rules.getOffset(it.value)) + dateTime = it.value.atOffset(ZoneOffset.UTC) sender.fireOnEventReceived(MountTimeChanged(this)) } } @@ -442,7 +464,7 @@ data class ASCOMMount( } } - if (ra != rightAscension || dec != declination) { + if (abs(ra - rightAscension) >= EPSILON || abs(dec - declination) >= EPSILON) { rightAscension = ra declination = dec @@ -463,6 +485,9 @@ data class ASCOMMount( companion object { + private const val EPSILON = 1 / 36000.0 * DEG2RAD + @JvmStatic private val LOG = loggerFor() + @JvmStatic private val SLEW_RATE_INCREMENT = BigDecimal("0.1") } } diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/rotators/ASCOMRotator.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/rotators/ASCOMRotator.kt new file mode 100644 index 000000000..26e372fe6 --- /dev/null +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/rotators/ASCOMRotator.kt @@ -0,0 +1,112 @@ +package nebulosa.alpaca.indi.device.rotators + +import nebulosa.alpaca.api.AlpacaRotatorService +import nebulosa.alpaca.api.ConfiguredDevice +import nebulosa.alpaca.indi.client.AlpacaClient +import nebulosa.alpaca.indi.device.ASCOMDevice +import nebulosa.indi.device.Device +import nebulosa.indi.device.rotator.* +import nebulosa.indi.protocol.INDIProtocol + +@Suppress("RedundantModalityModifier") +data class ASCOMRotator( + override val device: ConfiguredDevice, + override val service: AlpacaRotatorService, + override val sender: AlpacaClient, +) : ASCOMDevice(), Rotator { + + @Volatile final override var angle = 0.0 + @Volatile final override var minAngle = 0.0 + @Volatile final override var maxAngle = 360.0 + @Volatile final override var moving = false + @Volatile final override var canAbort = true + @Volatile final override var canHome = false + @Volatile final override var canSync = true + @Volatile final override var canReverse = false + @Volatile final override var reversed = false + @Volatile final override var hasBacklashCompensation = false + @Volatile final override var backslash = 0 + + override val snoopedDevices = emptyList() + + override fun onConnected() { + processCapabilities() + processPosition() + } + + override fun onDisconnected() = Unit + + override fun refresh(elapsedTimeInSeconds: Long) { + super.refresh(elapsedTimeInSeconds) + + if (connected) { + processPosition() + processMoving() + processReversed() + } + } + + override fun snoop(devices: Iterable) = Unit + + override fun moveRotator(angle: Double) { + service.moveTo(device.number, angle).doRequest() + } + + override fun syncRotator(angle: Double) { + if (canSync) { + service.sync(device.number, angle).doRequest() + } + } + + override fun homeRotator() = Unit + + override fun reverseRotator(enable: Boolean) { + if (canReverse) { + service.reverse(device.number, enable).doRequest() + } + } + + override fun abortRotator() { + if (canAbort) { + service.halt(device.number).doRequest() + } + } + + override fun handleMessage(message: INDIProtocol) = Unit + + private fun processCapabilities() { + service.canReverse(device.number).doRequest { + if (it.value != canReverse) { + canReverse = it.value + sender.fireOnEventReceived(RotatorCanReverseChanged(this)) + } + } + } + + private fun processPosition() { + service.position(device.number).doRequest { + if (it.value != angle) { + angle = it.value + sender.fireOnEventReceived(RotatorAngleChanged(this)) + } + } + } + + private fun processMoving() { + service.isMoving(device.number).doRequest { + if (it.value != moving) { + moving = it.value + sender.fireOnEventReceived(RotatorMovingChanged(this)) + } + } + } + + private fun processReversed() { + service.isReversed(device.number).doRequest { + if (it.value != reversed) { + reversed = it.value + sender.fireOnEventReceived(RotatorReversedChanged(this)) + } + } + } +} diff --git a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/wheels/ASCOMFilterWheel.kt b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/wheels/ASCOMFilterWheel.kt index 76218af80..413f7eee5 100644 --- a/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/wheels/ASCOMFilterWheel.kt +++ b/nebulosa-alpaca-indi/src/main/kotlin/nebulosa/alpaca/indi/device/wheels/ASCOMFilterWheel.kt @@ -5,9 +5,7 @@ import nebulosa.alpaca.api.ConfiguredDevice import nebulosa.alpaca.indi.client.AlpacaClient import nebulosa.alpaca.indi.device.ASCOMDevice import nebulosa.indi.device.Device -import nebulosa.indi.device.filterwheel.FilterWheel -import nebulosa.indi.device.filterwheel.FilterWheelNamesChanged -import nebulosa.indi.device.filterwheel.FilterWheelPositionChanged +import nebulosa.indi.device.filterwheel.* import nebulosa.indi.protocol.INDIProtocol @Suppress("RedundantModalityModifier") @@ -22,6 +20,8 @@ data class ASCOMFilterWheel( @Volatile final override var moving = false @Volatile final override var names = emptyList() + @Volatile private var targetPosition = 0 + override val snoopedDevices = emptyList() override fun onConnected() { @@ -29,11 +29,16 @@ data class ASCOMFilterWheel( processNames() } - override fun onDisconnected() {} + override fun onDisconnected() = Unit override fun moveTo(position: Int) { - if (position != this.position) { - service.position(device.number, position).doRequest() + if (position in 1..count && position != this.position) { + targetPosition = position - 1 + + if (service.position(device.number, targetPosition).doRequest() != null) { + moving = true + sender.fireOnEventReceived(FilterWheelMovingChanged(this)) + } } } @@ -42,36 +47,43 @@ data class ASCOMFilterWheel( if (connected) { processPosition() - processMoving() } } override fun names(names: Iterable) { this.names = names.toList() + sender.fireOnEventReceived(FilterWheelNamesChanged(this)) } - override fun snoop(devices: Iterable) {} - - override fun handleMessage(message: INDIProtocol) {} + override fun snoop(devices: Iterable) = Unit - private fun processMoving() {} + override fun handleMessage(message: INDIProtocol) = Unit private fun processPosition() { service.position(device.number).doRequest { - if (it.value != position) { - val prevPosition = position - position = it.value + val value = it.value + 1 - sender.fireOnEventReceived(FilterWheelPositionChanged(this, prevPosition)) + if (value >= 1 && value != position) { + position = value + sender.fireOnEventReceived(FilterWheelPositionChanged(this)) + + if (moving && it.value == targetPosition) { + moving = false + sender.fireOnEventReceived(FilterWheelMovingChanged(this)) + } } } } private fun processNames() { service.names(device.number).doRequest { + if (it.value.size != names.size) { + count = it.value.size + sender.fireOnEventReceived(FilterWheelCountChanged(this)) + } + if (it.value != names) { names = it.value - sender.fireOnEventReceived(FilterWheelNamesChanged(this)) } } 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..3dab4195e 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 @@ -90,8 +90,10 @@ class AstapPlateSolver(path: Path) : PlateSolver { val cd22 = ini.getProperty("CD2_2").toDouble() val dimensions = ini.getProperty("DIMENSIONS").split("x") - val width = cdelt1 * dimensions[0].trim().toDouble() - val height = cdelt2 * dimensions[1].trim().toDouble() + val widthInPixels = dimensions[0].trim().toDouble() + val heightInPixels = dimensions[1].trim().toDouble() + val width = cdelt1 * widthInPixels + val height = cdelt2 * heightInPixels val header = FitsHeader() header.add(FitsKeyword.CTYPE1, ctype1) @@ -109,7 +111,10 @@ class AstapPlateSolver(path: Path) : PlateSolver { header.add(FitsKeyword.CD2_1, cd21) header.add(FitsKeyword.CD2_2, cd22) - val solution = PlateSolution(true, crota2.deg, cdelt2.deg, crval1.deg, crval2.deg, width.deg, height.deg, header = header) + val solution = PlateSolution( + true, crota2.deg, cdelt2.deg, crval1.deg, crval2.deg, width.deg, height.deg, + widthInPixels = widthInPixels, heightInPixels = heightInPixels, header = header + ) LOG.info("astap solved. calibration={}", solution) diff --git a/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Camera.kt b/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Camera.kt index b27fe5e9b..a447c3a33 100644 --- a/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Camera.kt +++ b/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Camera.kt @@ -6,4 +6,8 @@ data class Camera( override val name: String = "", val cooled: Boolean = false, val sensor: Long = 0L, -) : Equipment +) : Equipment { + + override val isValid + get() = sensor > 0L +} diff --git a/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Equipment.kt b/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Equipment.kt index 609523b5a..29f1bb021 100644 --- a/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Equipment.kt +++ b/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Equipment.kt @@ -7,4 +7,6 @@ sealed interface Equipment { val brandName: String val name: String + + val isValid: Boolean } diff --git a/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Sensor.kt b/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Sensor.kt index 8d365110e..4de8f283d 100644 --- a/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Sensor.kt +++ b/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Sensor.kt @@ -14,4 +14,8 @@ data class Sensor( val adc: Int = 0, val color: SensorColor = SensorColor.MONO, val cameras: LongArray = LongArray(0), -) : Equipment +) : Equipment { + + override val isValid + get() = pixelWidth > 0.0 && pixelHeight > 0.0 && pixelSize > 0.0 +} diff --git a/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Telescope.kt b/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Telescope.kt index c67e1f26f..898ff2bf9 100644 --- a/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Telescope.kt +++ b/nebulosa-astrobin-api/src/main/kotlin/nebulosa/astrobin/api/Telescope.kt @@ -8,4 +8,8 @@ data class Telescope( val aperture: Double = 0.0, val minFocalLength: Double = 0.0, val maxFocalLength: Double = 0.0, -) : Equipment +) : Equipment { + + override val isValid + get() = aperture > 0.0 && (minFocalLength > 0.0 || maxFocalLength > 0.0) +} diff --git a/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/nova/NovaAstrometryNetService.kt b/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/nova/NovaAstrometryNetService.kt index 4574bbf5d..6f051c649 100644 --- a/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/nova/NovaAstrometryNetService.kt +++ b/nebulosa-astrometrynet/src/main/kotlin/nebulosa/astrometrynet/nova/NovaAstrometryNetService.kt @@ -26,20 +26,20 @@ class NovaAstrometryNetService( fun login(apiKey: String): Call { return FormBody.Builder() - .add("request-json", mapper.writeValueAsString(mapOf("apikey" to apiKey))) + .add("request-json", jsonMapper.writeValueAsString(mapOf("apikey" to apiKey))) .build() .let(service::login) } fun uploadFromUrl(upload: Upload): Call { return FormBody.Builder() - .add("request-json", mapper.writeValueAsString(upload)) + .add("request-json", jsonMapper.writeValueAsString(upload)) .build() .let(service::uploadFromUrl) } fun uploadFromFile(path: Path, upload: Upload): Call { - val requestJsonBody = mapper.writeValueAsBytes(upload).toRequestBody(TEXT_PLAIN_MEDIA_TYPE) + val requestJsonBody = jsonMapper.writeValueAsBytes(upload).toRequestBody(TEXT_PLAIN_MEDIA_TYPE) val fileName = "%s.%s".format(UUID.randomUUID(), path.extension) val fileBody = path.toFile().asRequestBody(OCTET_STREAM_MEDIA_TYPE) @@ -54,7 +54,7 @@ class NovaAstrometryNetService( } fun uploadFromImage(image: Image, upload: Upload): Call { - val requestJsonBody = mapper.writeValueAsBytes(upload).toRequestBody(TEXT_PLAIN_MEDIA_TYPE) + val requestJsonBody = jsonMapper.writeValueAsBytes(upload).toRequestBody(TEXT_PLAIN_MEDIA_TYPE) val fileName = "%s.fits".format(UUID.randomUUID()) val fileBody = object : RequestBody() { diff --git a/nebulosa-batch-processing/build.gradle.kts b/nebulosa-batch-processing/build.gradle.kts deleted file mode 100644 index 2381c9353..000000000 --- a/nebulosa-batch-processing/build.gradle.kts +++ /dev/null @@ -1,19 +0,0 @@ -plugins { - kotlin("jvm") - id("maven-publish") -} - -dependencies { - api(project(":nebulosa-common")) - api(libs.rx) - implementation(project(":nebulosa-log")) - testImplementation(project(":nebulosa-test")) -} - -publishing { - publications { - create("pluginMaven") { - from(components["java"]) - } - } -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt deleted file mode 100644 index b489d0ca6..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/AsyncJobLauncher.kt +++ /dev/null @@ -1,206 +0,0 @@ -package nebulosa.batch.processing - -import nebulosa.common.concurrency.cancel.CancellationListener -import nebulosa.common.concurrency.cancel.CancellationSource -import nebulosa.common.concurrency.latch.Pauseable -import nebulosa.log.debug -import nebulosa.log.loggerFor -import java.io.Closeable -import java.time.LocalDateTime -import java.util.concurrent.Executor - -open class AsyncJobLauncher(private val executor: Executor) : JobLauncher, StepInterceptor, CancellationListener { - - private val jobListeners = LinkedHashSet() - private val stepListeners = LinkedHashSet() - private val stepInterceptors = LinkedHashSet() - private val jobs = LinkedHashSet() - - override var stepHandler: StepHandler = DefaultStepHandler - - override fun registerJobExecutionListener(listener: JobExecutionListener): Boolean { - return jobListeners.add(listener) - } - - override fun unregisterJobExecutionListener(listener: JobExecutionListener): Boolean { - return jobListeners.remove(listener) - } - - override fun registerStepExecutionListener(listener: StepExecutionListener): Boolean { - return stepListeners.add(listener) - } - - override fun unregisterStepExecutionListener(listener: StepExecutionListener): Boolean { - return stepListeners.remove(listener) - } - - override fun registerStepInterceptor(interceptor: StepInterceptor): Boolean { - return stepInterceptors.add(interceptor) - } - - override fun unregisterStepInterceptor(interceptor: StepInterceptor): Boolean { - return stepInterceptors.remove(interceptor) - } - - override val size - get() = jobs.size - - override fun contains(element: JobExecution): Boolean { - return element in jobs - } - - override fun containsAll(elements: Collection): Boolean { - return elements.all { it in this } - } - - override fun isEmpty(): Boolean { - return jobs.isEmpty() - } - - override fun iterator(): Iterator { - return jobs.iterator() - } - - @Synchronized - override fun launch(job: Job, executionContext: ExecutionContext?): JobExecution { - var jobExecution = jobs.find { it.job === job } - - if (jobExecution != null) { - if (!jobExecution.isDone || jobExecution.isStopping) { - LOG.warn("unable to launch new job {}, because it is running or stopping.", job::class.simpleName) - return jobExecution - } - } - - val interceptors = ArrayList(stepInterceptors.size + 1) - interceptors.addAll(stepInterceptors) - interceptors.add(this) - - jobExecution = JobExecution(job, executionContext ?: jobExecution?.context ?: ExecutionContext(), this, interceptors) - - jobs.add(jobExecution) - - jobExecution.cancellationToken.listen(this) - - executor.execute { - jobExecution.status = JobStatus.STARTED - - LOG.debug { "job started. job={}".format(job) } - - job.beforeJob(jobExecution) - jobListeners.forEach { it.beforeJob(jobExecution) } - - val stepJobListeners = LinkedHashSet() - - try { - while (jobExecution.canContinue && job.hasNext(jobExecution)) { - val step = job.next(jobExecution) - - if (stepJobListeners.add(step)) { - step.beforeJob(jobExecution) - } - - val result = stepHandler.handle(step, StepExecution(step, jobExecution)) - result.get() - } - - jobExecution.status = if (jobExecution.isStopping) JobStatus.STOPPED else JobStatus.COMPLETED - jobExecution.complete() - - LOG.debug { "job finished. job={}".format(job) } - } catch (e: Throwable) { - LOG.error("job failed. job=$job, jobExecution=$jobExecution", e) - jobExecution.status = JobStatus.FAILED - jobExecution.completeExceptionally(e) - } finally { - jobExecution.finishedAt = LocalDateTime.now() - jobExecution.cancellationToken.unlisten(this) - } - - fun JobExecutionListener.afterJob() { - afterJob(jobExecution) - - if (this is Closeable) { - close() - } - if (this is PublishSubscribe<*>) { - onComplete() - } - if (this is MutableCollection<*>) { - clear() - } - } - - stepJobListeners.forEach { it.afterJob() } - jobListeners.forEach { it.afterJob() } - job.afterJob() - } - - return jobExecution - } - - override fun stop(mayInterruptIfRunning: Boolean) { - jobs.forEach { stop(it, mayInterruptIfRunning) } - } - - override fun stop(jobExecution: JobExecution, mayInterruptIfRunning: Boolean) { - if (!jobExecution.isDone && !jobExecution.isStopping) { - jobExecution.status = JobStatus.STOPPING - jobExecution.job.stop(mayInterruptIfRunning) - - if (!jobExecution.cancellationToken.isDone) { - jobExecution.cancellationToken.cancel(mayInterruptIfRunning) - } - } - } - - override fun pause(jobExecution: JobExecution) { - if (!jobExecution.isDone && !jobExecution.isPaused) { - val job = jobExecution.job - - if (job is Pauseable) { - jobExecution.status = JobStatus.PAUSED - job.pause() - } - - if (!jobExecution.cancellationToken.isDone) { - jobExecution.cancellationToken.pause() - } - } - } - - override fun unpause(jobExecution: JobExecution) { - if (!jobExecution.isDone && jobExecution.isPaused) { - val job = jobExecution.job - - if (job is Pauseable) { - job.unpause() - jobExecution.status = JobStatus.STARTED - } - - if (!jobExecution.cancellationToken.isDone) { - jobExecution.cancellationToken.unpause() - } - } - } - - override fun intercept(chain: StepChain): StepResult { - stepListeners.forEach { it.beforeStep(chain.stepExecution) } - val result = chain.step.execute(chain.stepExecution) - stepListeners.forEach { it.afterStep(chain.stepExecution) } - return result - } - - override fun accept(source: CancellationSource) { - if (source is CancellationSource.Cancel) { - stop(source.mayInterruptIfRunning) - } - } - - override fun toString() = "AsyncJobLauncher" - - companion object { - - @JvmStatic private val LOG = loggerFor() - } -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/DefaultStepHandler.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/DefaultStepHandler.kt deleted file mode 100644 index 86ad96e04..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/DefaultStepHandler.kt +++ /dev/null @@ -1,40 +0,0 @@ -package nebulosa.batch.processing - -import nebulosa.log.debug -import nebulosa.log.loggerFor - -object DefaultStepHandler : StepHandler { - - @JvmStatic private val LOG = loggerFor() - - override fun handle(step: Step, stepExecution: StepExecution): StepResult { - val jobLauncher = stepExecution.jobExecution.jobLauncher - - when (step) { - is SplitStep -> { - step.beforeStep(stepExecution) - step.parallelStream().forEach { jobLauncher.stepHandler.handle(it, stepExecution) } - step.afterStep(stepExecution) - } - is FlowStep -> { - step.beforeStep(stepExecution) - step.forEach { jobLauncher.stepHandler.handle(it, stepExecution) } - step.afterStep(stepExecution) - } - else -> { - val chain = StepInterceptorChain(stepExecution.jobExecution.stepInterceptors, step, stepExecution) - - LOG.debug { "step started. step=%s, context=%s".format(step, stepExecution.context) } - - while (stepExecution.jobExecution.canContinue) { - val status = chain.proceed().get() - if (status != RepeatStatus.CONTINUABLE) break - } - - LOG.debug { "step finished. step=%s, context=%s".format(step, stepExecution.context) } - } - } - - return StepResult.FINISHED - } -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/ExecutionContext.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/ExecutionContext.kt deleted file mode 100644 index f1290ba0c..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/ExecutionContext.kt +++ /dev/null @@ -1,63 +0,0 @@ -package nebulosa.batch.processing - -import java.nio.file.Path -import java.time.Duration -import java.util.concurrent.ConcurrentHashMap - -open class ExecutionContext : ConcurrentHashMap { - - constructor(initialCapacity: Int = 64) : super(initialCapacity) - - constructor(context: ExecutionContext) : super(context) - - companion object { - - @JvmStatic - fun ExecutionContext.getOrNull(key: String, type: Class) = if (containsKey(key)) type.cast(this[key]) - else null - - @JvmStatic - fun ExecutionContext.getBoolean(key: String, value: Boolean = false) = if (containsKey(key)) this[key] as Boolean - else value - - @JvmStatic - fun ExecutionContext.getBooleanOrNull(key: String) = if (containsKey(key)) this[key] as? Boolean - else null - - @JvmStatic - fun ExecutionContext.getInt(key: String, value: Int = 0) = if (containsKey(key)) this[key] as Int - else value - - @JvmStatic - fun ExecutionContext.getIntOrNull(key: String) = if (containsKey(key)) this[key] as? Int - else null - - @JvmStatic - fun ExecutionContext.getDouble(key: String, value: Double = 0.0) = if (containsKey(key)) this[key] as Double - else value - - @JvmStatic - fun ExecutionContext.getDoubleOrNull(key: String) = if (containsKey(key)) this[key] as? Double - else null - - @JvmStatic - fun ExecutionContext.getText(key: String, value: String = "") = if (containsKey(key)) this[key] as String - else value - - @JvmStatic - fun ExecutionContext.getTextOrNull(key: String) = if (containsKey(key)) this[key] as? String - else null - - @JvmStatic - fun ExecutionContext.getDuration(key: String, value: Duration = Duration.ZERO) = if (containsKey(key)) this[key] as Duration - else value - - @JvmStatic - fun ExecutionContext.getDurationOrNull(key: String) = if (containsKey(key)) this[key] as? Duration - else null - - @JvmStatic - fun ExecutionContext.getPath(key: String) = if (containsKey(key)) this[key] as? Path - else null - } -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/FlowStep.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/FlowStep.kt deleted file mode 100644 index ae3f1a49f..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/FlowStep.kt +++ /dev/null @@ -1,12 +0,0 @@ -package nebulosa.batch.processing - -interface FlowStep : Step, StepExecutionListener, Collection { - - override fun execute(stepExecution: StepExecution): StepResult { - return stepExecution.jobExecution.jobLauncher.stepHandler.handle(this, stepExecution) - } - - override fun stop(mayInterruptIfRunning: Boolean) { - forEach { it.stop(mayInterruptIfRunning) } - } -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Job.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Job.kt deleted file mode 100644 index 79a3243bd..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Job.kt +++ /dev/null @@ -1,16 +0,0 @@ -package nebulosa.batch.processing - -interface Job : JobExecutionListener, Stoppable { - - val id: String - - fun hasNext(jobExecution: JobExecution): Boolean - - fun next(jobExecution: JobExecution): Step - - override fun beforeJob(jobExecution: JobExecution) = Unit - - override fun afterJob(jobExecution: JobExecution) = Unit - - operator fun contains(data: Any): Boolean -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt deleted file mode 100644 index 5556b12c5..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecution.kt +++ /dev/null @@ -1,93 +0,0 @@ -package nebulosa.batch.processing - -import nebulosa.common.concurrency.cancel.CancellationToken -import java.time.LocalDateTime -import java.util.concurrent.CompletableFuture -import java.util.concurrent.ExecutionException -import java.util.concurrent.TimeUnit - -class JobExecution( - val job: Job, - val context: ExecutionContext, - val jobLauncher: JobLauncher, - val stepInterceptors: List, - val startedAt: LocalDateTime = LocalDateTime.now(), -) { - - var status = JobStatus.STARTING - internal set - - var finishedAt: LocalDateTime? = null - internal set - - @JvmField internal val completable = CompletableFuture() - @JvmField val cancellationToken = CancellationToken() - - inline val canContinue - get() = status == JobStatus.STARTED || status == JobStatus.PAUSED - - inline val isStopping - get() = status == JobStatus.STOPPING - - inline val isStopped - get() = status == JobStatus.STOPPED - - inline val isPaused - get() = status == JobStatus.PAUSED - - inline val isCompleted - get() = status == JobStatus.COMPLETED - - inline val isFailed - get() = status == JobStatus.FAILED - - val isDone - get() = isCompleted || isFailed || isStopped - - fun waitForCompletion(timeout: Long = 0L, unit: TimeUnit = TimeUnit.MILLISECONDS): Boolean { - try { - if (timeout <= 0L) completable.get() - else return completable.get(timeout, unit) - return true - } catch (e: ExecutionException) { - throw e.cause ?: e - } - } - - internal fun complete() { - completable.complete(true) - } - - internal fun completeExceptionally(e: Throwable) { - completable.completeExceptionally(e) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is JobExecution) return false - - if (job != other.job) return false - if (context != other.context) return false - if (jobLauncher != other.jobLauncher) return false - if (stepInterceptors != other.stepInterceptors) return false - if (startedAt != other.startedAt) return false - if (status != other.status) return false - if (finishedAt != other.finishedAt) return false - - return true - } - - override fun hashCode(): Int { - var result = job.hashCode() - result = 31 * result + context.hashCode() - result = 31 * result + jobLauncher.hashCode() - result = 31 * result + stepInterceptors.hashCode() - result = 31 * result + startedAt.hashCode() - result = 31 * result + status.hashCode() - result = 31 * result + (finishedAt?.hashCode() ?: 0) - return result - } - - override fun toString() = "JobExecution(job=$job, context=$context, startedAt=$startedAt," + - " status=$status, finishedAt=$finishedAt)" -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecutionListener.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecutionListener.kt deleted file mode 100644 index 70508ddc9..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecutionListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package nebulosa.batch.processing - -interface JobExecutionListener { - - fun beforeJob(jobExecution: JobExecution) = Unit - - fun afterJob(jobExecution: JobExecution) = Unit -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecutor.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecutor.kt deleted file mode 100644 index e9f7567a3..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobExecutor.kt +++ /dev/null @@ -1,54 +0,0 @@ -package nebulosa.batch.processing - -import java.util.* - -abstract class JobExecutor { - - protected abstract val jobLauncher: JobLauncher - - @PublishedApi internal val jobExecutions = LinkedList() - - protected fun register(jobExecution: JobExecution) { - jobExecutions.add(jobExecution) - } - - protected inline fun findJobExecutionWith(test: Job.() -> Boolean): JobExecution? { - for (i in jobExecutions.indices.reversed()) { - val jobExecution = jobExecutions[i] - val job = jobExecution.job - - if (!jobExecution.isDone && job.test()) { - return jobExecution - } - } - - return null - } - - protected fun findJobExecutionWithAny(vararg data: Any): JobExecution? { - return findJobExecutionWith { data.any { it in this } } - } - - fun findJobExecution(id: String): JobExecution? { - return jobExecutions.find { it.job.id == id } - } - - fun stop(id: String) { - val jobExecution = findJobExecution(id) ?: return - jobLauncher.stop(jobExecution) - } - - fun pause(id: String) { - val jobExecution = findJobExecution(id) ?: return - jobLauncher.pause(jobExecution) - } - - fun unpause(id: String) { - val jobExecution = findJobExecution(id) ?: return - jobLauncher.unpause(jobExecution) - } - - fun isRunning(id: String): Boolean { - return findJobExecution(id) != null - } -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobLauncher.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobLauncher.kt deleted file mode 100644 index 71683c80a..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobLauncher.kt +++ /dev/null @@ -1,26 +0,0 @@ -package nebulosa.batch.processing - -interface JobLauncher : Collection, Stoppable { - - val stepHandler: StepHandler - - fun registerJobExecutionListener(listener: JobExecutionListener): Boolean - - fun unregisterJobExecutionListener(listener: JobExecutionListener): Boolean - - fun registerStepExecutionListener(listener: StepExecutionListener): Boolean - - fun unregisterStepExecutionListener(listener: StepExecutionListener): Boolean - - fun registerStepInterceptor(interceptor: StepInterceptor): Boolean - - fun unregisterStepInterceptor(interceptor: StepInterceptor): Boolean - - fun launch(job: Job, executionContext: ExecutionContext? = null): JobExecution - - fun stop(jobExecution: JobExecution, mayInterruptIfRunning: Boolean = true) - - fun pause(jobExecution: JobExecution) - - fun unpause(jobExecution: JobExecution) -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobStatus.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobStatus.kt deleted file mode 100644 index e680702fc..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/JobStatus.kt +++ /dev/null @@ -1,12 +0,0 @@ -package nebulosa.batch.processing - -enum class JobStatus { - STARTING, - STARTED, - STOPPING, - STOPPED, - PAUSED, - FAILED, - COMPLETED, - ABANDONED, -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/PublishSubscribe.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/PublishSubscribe.kt deleted file mode 100644 index a2a26aa00..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/PublishSubscribe.kt +++ /dev/null @@ -1,44 +0,0 @@ -package nebulosa.batch.processing - -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.ObservableSource -import io.reactivex.rxjava3.core.Observer -import io.reactivex.rxjava3.disposables.Disposable -import io.reactivex.rxjava3.functions.Consumer -import io.reactivex.rxjava3.subjects.Subject -import java.io.Closeable - -interface PublishSubscribe : ObservableSource, Observer, Closeable { - - val subject: Subject - - fun Observable.transform() = this - - fun subscribe(onNext: Consumer): Disposable { - return subject.transform().subscribe(onNext) - } - - override fun subscribe(observer: Observer) { - return subject.transform().subscribe(observer) - } - - override fun onSubscribe(disposable: Disposable) { - subject.onSubscribe(disposable) - } - - override fun onNext(event: T) { - subject.onNext(event) - } - - override fun onError(e: Throwable) { - subject.onError(e) - } - - override fun onComplete() { - subject.onComplete() - } - - override fun close() { - onComplete() - } -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/RepeatStatus.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/RepeatStatus.kt deleted file mode 100644 index aa45a61ba..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/RepeatStatus.kt +++ /dev/null @@ -1,6 +0,0 @@ -package nebulosa.batch.processing - -enum class RepeatStatus { - CONTINUABLE, - FINISHED, -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleFlowStep.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleFlowStep.kt deleted file mode 100644 index 8a557ff5f..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleFlowStep.kt +++ /dev/null @@ -1,18 +0,0 @@ -package nebulosa.batch.processing - -open class SimpleFlowStep : FlowStep, ArrayList { - - constructor(initialCapacity: Int = 4) : super(initialCapacity) - - constructor(steps: Collection) : super(steps) - - constructor(vararg steps: Step) : this(steps.toList()) - - override fun beforeJob(jobExecution: JobExecution) { - forEach { it.beforeJob(jobExecution) } - } - - override fun afterJob(jobExecution: JobExecution) { - forEach { it.afterJob(jobExecution) } - } -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleJob.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleJob.kt deleted file mode 100644 index adf372f2d..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleJob.kt +++ /dev/null @@ -1,93 +0,0 @@ -package nebulosa.batch.processing - -import nebulosa.common.concurrency.latch.Pauseable -import java.util.* - -abstract class SimpleJob : Job, Pauseable, Iterable { - - private val steps = ArrayList() - - constructor(steps: Collection) { - this.steps.addAll(steps) - } - - constructor(vararg steps: Step) { - steps.forEach(this.steps::add) - } - - override val id = UUID.randomUUID().toString() - - @Volatile private var position = 0 - @Volatile private var isEnded = false - - protected fun register(step: Step): Boolean { - return steps.add(step) - } - - protected fun unregister(step: Step): Boolean { - return steps.remove(step) - } - - protected fun clear() { - return steps.clear() - } - - final override fun hasNext(jobExecution: JobExecution): Boolean { - return !isEnded && position < steps.size - } - - final override fun next(jobExecution: JobExecution): Step { - check(!isEnded) { "this job is ended" } - return steps[position++] - } - - final override fun stop(mayInterruptIfRunning: Boolean) { - if (isEnded) return - - isEnded = true - - if (position in 1..steps.size) { - steps[position - 1].stop(mayInterruptIfRunning) - } - } - - final override val isPaused - get() = steps.any { it !== this && it is Pauseable && it.isPaused } - - final override fun pause() { - if (isEnded) return - - if (position in 1..steps.size) { - val step = steps[position - 1] - - if (step is Pauseable) { - step.pause() - } - } - } - - final override fun unpause() { - if (isEnded) return - - if (position in 1..steps.size) { - val step = steps[position - 1] - - if (step is Pauseable) { - step.unpause() - } - } - } - - fun reset() { - isEnded = false - position = 0 - } - - final override fun iterator(): Iterator { - return steps.iterator() - } - - override fun contains(data: Any): Boolean { - return data is Step && data in steps - } -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleSplitStep.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleSplitStep.kt deleted file mode 100644 index ba17430ae..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SimpleSplitStep.kt +++ /dev/null @@ -1,10 +0,0 @@ -package nebulosa.batch.processing - -open class SimpleSplitStep : SimpleFlowStep, SplitStep { - - constructor(initialCapacity: Int = 4) : super(initialCapacity) - - constructor(steps: Collection) : super(steps) - - constructor(vararg steps: Step) : this(steps.toList()) -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SplitStep.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SplitStep.kt deleted file mode 100644 index a91bc8fa1..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/SplitStep.kt +++ /dev/null @@ -1,3 +0,0 @@ -package nebulosa.batch.processing - -interface SplitStep : FlowStep diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Step.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Step.kt deleted file mode 100644 index ab62c8cb3..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Step.kt +++ /dev/null @@ -1,18 +0,0 @@ -package nebulosa.batch.processing - -interface Step : Stoppable, JobExecutionListener { - - fun execute(stepExecution: StepExecution): StepResult - - fun executeSingle(stepExecution: StepExecution): StepResult { - beforeJob(stepExecution.jobExecution) - - try { - return execute(stepExecution) - } finally { - afterJob(stepExecution.jobExecution) - } - } - - override fun stop(mayInterruptIfRunning: Boolean) = Unit -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepChain.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepChain.kt deleted file mode 100644 index 4e4d97cb4..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepChain.kt +++ /dev/null @@ -1,10 +0,0 @@ -package nebulosa.batch.processing - -interface StepChain { - - val step: Step - - val stepExecution: StepExecution - - fun proceed(): StepResult -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepExecution.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepExecution.kt deleted file mode 100644 index 56538f1b7..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepExecution.kt +++ /dev/null @@ -1,10 +0,0 @@ -package nebulosa.batch.processing - -data class StepExecution( - val step: Step, - val jobExecution: JobExecution, -) { - - inline val context - get() = jobExecution.context -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepExecutionListener.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepExecutionListener.kt deleted file mode 100644 index 009528698..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepExecutionListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package nebulosa.batch.processing - -interface StepExecutionListener { - - fun beforeStep(stepExecution: StepExecution) = Unit - - fun afterStep(stepExecution: StepExecution) = Unit -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepHandler.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepHandler.kt deleted file mode 100644 index 0e6ad8bd0..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepHandler.kt +++ /dev/null @@ -1,6 +0,0 @@ -package nebulosa.batch.processing - -interface StepHandler { - - fun handle(step: Step, stepExecution: StepExecution): StepResult -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepInterceptor.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepInterceptor.kt deleted file mode 100644 index 80759da03..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepInterceptor.kt +++ /dev/null @@ -1,6 +0,0 @@ -package nebulosa.batch.processing - -interface StepInterceptor { - - fun intercept(chain: StepChain): StepResult -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepInterceptorChain.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepInterceptorChain.kt deleted file mode 100644 index d1ed9a905..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepInterceptorChain.kt +++ /dev/null @@ -1,15 +0,0 @@ -package nebulosa.batch.processing - -data class StepInterceptorChain( - private val interceptors: List, - override val step: Step, - override val stepExecution: StepExecution, - private val index: Int = 0, -) : StepChain { - - override fun proceed(): StepResult { - val next = StepInterceptorChain(interceptors, step, stepExecution, index + 1) - val interceptor = interceptors[index] - return interceptor.intercept(next) - } -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepResult.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepResult.kt deleted file mode 100644 index e3d044cd8..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/StepResult.kt +++ /dev/null @@ -1,23 +0,0 @@ -package nebulosa.batch.processing - -import java.util.concurrent.CompletableFuture -import java.util.concurrent.Future - -data class StepResult(@JvmField internal val completable: CompletableFuture) : Future by completable { - - constructor() : this(CompletableFuture()) - - fun complete(status: RepeatStatus): Boolean { - return completable.complete(status) - } - - fun completeExceptionally(e: Throwable): Boolean { - return completable.completeExceptionally(e) - } - - companion object { - - @JvmStatic val CONTINUABLE = StepResult(CompletableFuture.completedFuture(RepeatStatus.CONTINUABLE)) - @JvmStatic val FINISHED = StepResult(CompletableFuture.completedFuture(RepeatStatus.FINISHED)) - } -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Stoppable.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Stoppable.kt deleted file mode 100644 index c7c7f48a2..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/Stoppable.kt +++ /dev/null @@ -1,6 +0,0 @@ -package nebulosa.batch.processing - -interface Stoppable { - - fun stop(mayInterruptIfRunning: Boolean = true) -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/delay/DelayStep.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/delay/DelayStep.kt deleted file mode 100644 index 8ff522748..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/delay/DelayStep.kt +++ /dev/null @@ -1,72 +0,0 @@ -package nebulosa.batch.processing.delay - -import nebulosa.batch.processing.JobExecution -import nebulosa.batch.processing.Step -import nebulosa.batch.processing.StepExecution -import nebulosa.batch.processing.StepResult -import java.time.Duration - -data class DelayStep(@JvmField val duration: Duration) : Step { - - private val listeners = LinkedHashSet() - - @Volatile private var aborted = false - - fun registerDelayStepListener(listener: DelayStepListener) { - listeners.add(listener) - } - - fun unregisterDelayStepListener(listener: DelayStepListener) { - listeners.remove(listener) - } - - override fun execute(stepExecution: StepExecution): StepResult { - var remainingTime = duration - - if (!aborted && remainingTime > Duration.ZERO) { - while (!aborted && remainingTime > Duration.ZERO) { - val waitTime = minOf(remainingTime, DELAY_INTERVAL) - - if (waitTime > Duration.ZERO) { - stepExecution.context[REMAINING_TIME] = remainingTime - stepExecution.context[WAIT_TIME] = waitTime - stepExecution.context[WAITING] = true - - val progress = (duration.toNanos() - remainingTime.toNanos()) / duration.toNanos().toDouble() - stepExecution.context[PROGRESS] = progress - - listeners.forEach { it.onDelayElapsed(this, stepExecution) } - Thread.sleep(waitTime.toMillis()) - remainingTime -= waitTime - } - } - - stepExecution.context[REMAINING_TIME] = Duration.ZERO - stepExecution.context[WAIT_TIME] = Duration.ZERO - stepExecution.context[PROGRESS] = 1.0 - stepExecution.context[WAITING] = false - - listeners.forEach { it.onDelayElapsed(this, stepExecution) } - } - - return StepResult.FINISHED - } - - override fun stop(mayInterruptIfRunning: Boolean) { - aborted = true - } - - override fun afterJob(jobExecution: JobExecution) { - listeners.clear() - } - - companion object { - - const val REMAINING_TIME = "DELAY.REMAINING_TIME" - const val WAIT_TIME = "DELAY.WAIT_TIME" - const val PROGRESS = "DELAY.PROGRESS" - const val WAITING = "DELAY.WAITING" - - @JvmField val DELAY_INTERVAL = Duration.ofMillis(500)!! - } -} diff --git a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/delay/DelayStepListener.kt b/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/delay/DelayStepListener.kt deleted file mode 100644 index 04847c947..000000000 --- a/nebulosa-batch-processing/src/main/kotlin/nebulosa/batch/processing/delay/DelayStepListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package nebulosa.batch.processing.delay - -import nebulosa.batch.processing.StepExecution - -fun interface DelayStepListener { - - fun onDelayElapsed(step: DelayStep, stepExecution: StepExecution) -} diff --git a/nebulosa-batch-processing/src/test/kotlin/BatchProcessingTest.kt b/nebulosa-batch-processing/src/test/kotlin/BatchProcessingTest.kt deleted file mode 100644 index 640c8a9bd..000000000 --- a/nebulosa-batch-processing/src/test/kotlin/BatchProcessingTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -import io.kotest.core.spec.style.StringSpec -import io.kotest.matchers.booleans.shouldBeTrue -import io.kotest.matchers.doubles.shouldBeGreaterThanOrEqual -import io.kotest.matchers.shouldBe -import nebulosa.batch.processing.* -import nebulosa.log.loggerFor -import java.util.concurrent.Executors -import kotlin.concurrent.thread - -class BatchProcessingTest : StringSpec() { - - init { - val launcher = AsyncJobLauncher(Executors.newSingleThreadExecutor()) - - "single" { - val jobExecution = launcher.launch(MathJob(listOf(SumStep()))) - jobExecution.waitForCompletion() - jobExecution.context["VALUE"] shouldBe 1.0 - } - "multiple" { - val jobExecution = launcher.launch(MathJob(listOf(SumStep(), SumStep()))) - jobExecution.waitForCompletion() - jobExecution.context["VALUE"] shouldBe 2.0 - } - "split" { - val jobExecution = launcher.launch(MathJob(listOf(SplitSumStep()))) - jobExecution.waitForCompletion() - jobExecution.context["VALUE"] shouldBe N.toDouble() - } - "flow" { - val jobExecution = launcher.launch(MathJob(listOf(FlowSumStep()))) - jobExecution.waitForCompletion() - jobExecution.context["VALUE"] shouldBe N.toDouble() - } - "split flow" { - val jobExecution = launcher.launch(MathJob(listOf(SimpleSplitStep(FlowSumStep(), FlowSumStep())))) - jobExecution.waitForCompletion() - jobExecution.context["VALUE"] shouldBe (N * 2).toDouble() - } - "stop" { - val jobExecution = launcher.launch(MathJob((0..7).map { SumStep() })) - thread { Thread.sleep(5000); launcher.stop(jobExecution) } - jobExecution.waitForCompletion() - jobExecution.context["VALUE"] as Double shouldBeGreaterThanOrEqual 3.0 - jobExecution.isStopped.shouldBeTrue() - } - "repeatable" { - val jobExecution = launcher.launch(MathJob(listOf(SumStep()), 10.0)) - jobExecution.waitForCompletion() - jobExecution.context["VALUE"] shouldBe 20.0 - } - } - - private class MathJob( - steps: List, - private val initialValue: Double = 0.0, - ) : SimpleJob(steps) { - - override fun beforeJob(jobExecution: JobExecution) { - jobExecution.context["VALUE"] = initialValue - } - } - - private abstract class MathStep : Step { - - @Volatile private var running = false - - protected abstract fun compute(value: Double): Double - - final override fun execute(stepExecution: StepExecution): StepResult { - var sleepCount = 0 - - val jobExecution = stepExecution.jobExecution - running = jobExecution.canContinue - - while (running && sleepCount++ < 100) { - Thread.sleep(10) - } - - if (running) { - synchronized(jobExecution) { - val value = jobExecution.context["VALUE"]!! as Double - LOG.info("executing ${javaClass.simpleName}: $value") - jobExecution.context["VALUE"] = compute(value) - - if (value >= 10.0 && value < 19.0) { - return StepResult.CONTINUABLE - } - } - } else { - println("stopped") - } - - return StepResult.FINISHED - } - - override fun stop(mayInterruptIfRunning: Boolean) { - running = false - } - } - - private class SumStep : MathStep() { - - override fun compute(value: Double): Double { - return value + 1.0 - } - } - - private class SplitSumStep : SimpleSplitStep() { - - init { - repeat(N) { - add(SumStep()) - } - } - } - - private class FlowSumStep : SimpleFlowStep() { - - init { - repeat(N) { - add(SumStep()) - } - } - } - - companion object { - - @JvmStatic private val LOG = loggerFor() - @JvmStatic private val N = Runtime.getRuntime().availableProcessors() - } -} diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/Resettable.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/Resettable.kt new file mode 100644 index 000000000..204395136 --- /dev/null +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/Resettable.kt @@ -0,0 +1,6 @@ +package nebulosa.common + +fun interface Resettable { + + fun reset() +} diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/atomic/Incrementer.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/atomic/Incrementer.kt index 46d9ede72..981bd1d65 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/atomic/Incrementer.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/atomic/Incrementer.kt @@ -1,14 +1,15 @@ package nebulosa.common.concurrency.atomic +import nebulosa.common.Resettable import java.util.concurrent.atomic.AtomicLong -class Incrementer(initialValue: Long = 0L) : Number() { +class Incrementer(initialValue: Long = 0L) : Number(), Resettable { private val incrementer = AtomicLong(initialValue) fun increment() = incrementer.incrementAndGet() - fun reset() = incrementer.set(0) + override fun reset() = incrementer.set(0) fun get() = incrementer.get() diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationListener.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationListener.kt new file mode 100644 index 000000000..c2ea9a3ae --- /dev/null +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationListener.kt @@ -0,0 +1,6 @@ +package nebulosa.common.concurrency.cancel + +fun interface CancellationListener { + + fun onCancel(source: CancellationSource) +} diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationSource.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationSource.kt index 5dec99c93..c57bfe088 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationSource.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationSource.kt @@ -9,4 +9,6 @@ interface CancellationSource { data class Cancel(val mayInterruptIfRunning: Boolean) : CancellationSource data object Close : CancellationSource + + data class Exceptionally(val exception: Throwable) : CancellationSource } diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt index 82311f2cf..3ee538fb8 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt @@ -5,12 +5,9 @@ import java.io.Closeable import java.util.concurrent.CompletableFuture import java.util.concurrent.Future import java.util.concurrent.TimeUnit -import java.util.function.Consumer -typealias CancellationListener = Consumer - -class CancellationToken private constructor(private val completable: CompletableFuture?) : Pauser(), Closeable, - Future { +class CancellationToken private constructor(private val completable: CompletableFuture?) : + Pauser(), Closeable, Future { constructor() : this(CompletableFuture()) @@ -22,7 +19,7 @@ class CancellationToken private constructor(private val completable: Completable unpause() if (source != null) { - listeners.forEach { it.accept(source) } + listeners.forEach { it.onCancel(source) } } listeners.clear() @@ -34,7 +31,7 @@ class CancellationToken private constructor(private val completable: Completable fun listen(listener: CancellationListener) { if (completable != null) { if (isDone) { - listener.accept(CancellationSource.Listen) + listener.onCancel(CancellationSource.Listen) } else { listeners.add(listener) } diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/CountUpDownLatch.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/CountUpDownLatch.kt index 7499f9448..ffdc616fa 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/CountUpDownLatch.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/CountUpDownLatch.kt @@ -59,7 +59,7 @@ class CountUpDownLatch(initialCount: Int = 0) : Supplier, CancellationL return n >= 0 && sync.tryAcquireSharedNanos(n, timeout.toNanos()) } - override fun accept(source: CancellationSource) { + override fun onCancel(source: CancellationSource) { if (source !== CancellationSource.None) { reset() } diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/PauseListener.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/PauseListener.kt new file mode 100644 index 000000000..14ac8836f --- /dev/null +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/PauseListener.kt @@ -0,0 +1,6 @@ +package nebulosa.common.concurrency.latch + +fun interface PauseListener { + + fun onPause(paused: Boolean) +} diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauser.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauser.kt index 0e3f2a106..b6983a70f 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauser.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/latch/Pauser.kt @@ -7,35 +7,53 @@ import java.util.concurrent.TimeUnit open class Pauser : Pauseable, Closeable { private val latch = CountUpDownLatch() + private val listeners = LinkedHashSet() final override val isPaused get() = !latch.get() + @Synchronized + fun listenToPause(listener: PauseListener) { + listeners.add(listener) + } + + @Synchronized + fun unlistenToPause(listener: PauseListener) { + listeners.remove(listener) + } + + fun clearPauseListeners() { + listeners.clear() + } + final override fun pause() { if (latch.get()) { latch.countUp(1) + listeners.forEach { it.onPause(true) } } } final override fun unpause() { if (!latch.get()) { latch.reset() + listeners.forEach { it.onPause(false) } } } override fun close() { unpause() + clearPauseListeners() } - fun waitIfPaused() { + fun waitForPause() { latch.await() } - fun waitIfPaused(timeout: Long, unit: TimeUnit): Boolean { + fun waitForPause(timeout: Long, unit: TimeUnit): Boolean { return latch.await(timeout, unit) } - fun waitIfPaused(timeout: Duration): Boolean { + fun waitForPause(timeout: Duration): Boolean { return latch.await(timeout) } } diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/time/Stopwatch.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/time/Stopwatch.kt index 4c0ac9035..11a47070c 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/time/Stopwatch.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/time/Stopwatch.kt @@ -1,5 +1,6 @@ package nebulosa.common.time +import nebulosa.common.Resettable import java.time.Duration /** @@ -15,7 +16,7 @@ import java.time.Duration * * The stopwatch is started by calling [start]. */ -class Stopwatch { +class Stopwatch : Resettable { @Volatile private var start = 0L @Volatile private var stop = 0L @@ -100,7 +101,7 @@ class Stopwatch { * This method does not stop or start the [Stopwatch]. */ @Synchronized - fun reset() { + override fun reset() { start = if (isStopped) stop else System.nanoTime() } } diff --git a/nebulosa-common/src/test/kotlin/PauserTest.kt b/nebulosa-common/src/test/kotlin/PauserTest.kt index 102d1ef07..45724801f 100644 --- a/nebulosa-common/src/test/kotlin/PauserTest.kt +++ b/nebulosa-common/src/test/kotlin/PauserTest.kt @@ -14,7 +14,7 @@ class PauserTest : StringSpec() { pauser.pause() pauser.isPaused.shouldBeTrue() thread { Thread.sleep(1000); pauser.unpause() } - pauser.waitIfPaused() + pauser.waitForPause() pauser.isPaused.shouldBeFalse() } "pause and not wait for unpause" { @@ -23,7 +23,7 @@ class PauserTest : StringSpec() { pauser.pause() pauser.isPaused.shouldBeTrue() thread { Thread.sleep(1000); pauser.unpause() } - pauser.waitIfPaused(500, TimeUnit.MILLISECONDS).shouldBeFalse() + pauser.waitForPause(500, TimeUnit.MILLISECONDS).shouldBeFalse() pauser.isPaused.shouldBeTrue() } } diff --git a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt index cdccec460..02514c2df 100644 --- a/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt +++ b/nebulosa-erfa/src/main/kotlin/nebulosa/erfa/Erfa.kt @@ -1,4 +1,4 @@ -@file:Suppress("PrivatePropertyName", "NOTHING_TO_INLINE", "FloatingPointLiteralPrecision") +@file:Suppress("PrivatePropertyName", "NOTHING_TO_INLINE", "FloatingPointLiteralPrecision", "UnnecessaryVariable") package nebulosa.erfa @@ -87,9 +87,9 @@ fun eraAb(pnat: Vector3D, v: Vector3D, s: Distance, bm1: Double): Vector3D { * HA is returned in the range +/-pi. Declination is returned in the range +/-pi/2. * * The latitude phi is pi/2 minus the angle between the Earth's - * rotation axis and the adopted zenith. In many applications it + * rotation axis and the adopted zenith. In many applications it * will be sufficient to use the published geodetic latitude of the - * site. In very precise (sub-arcsecond) applications, phi can be + * site. In very precise (sub-arcsecond) applications, phi can be * corrected for polar motion. * * The azimuth az must be with respect to the rotational north pole, @@ -1399,10 +1399,10 @@ private val E1 = arrayOf( * EE = dpsi * cos(eps) * * where dpsi is the nutation in longitude and eps is the obliquity - * of date. However, if the rotation of the Earth were constant in + * of date. However, if the rotation of the Earth were constant in * an inertial frame the classical formulation would lead to * apparent irregularities in the UT1 timescale traceable to side- - * effects of precession-nutation. In order to eliminate these + * effects of precession-nutation. In order to eliminate these * effects from UT1, "complementary terms" were introduced in 1994 * (IAU, 1994) and took effect from 1997 (Capitaine and Gontier, * 1993): @@ -1411,7 +1411,7 @@ private val E1 = arrayOf( * * By convention, the complementary terms are included as part of * the equation of the equinoxes rather than as part of the mean - * Sidereal Time. This slightly compromises the "geometrical" + * Sidereal Time. This slightly compromises the "geometrical" * interpretation of mean sidereal time but is otherwise * inconsequential. * @@ -1497,7 +1497,7 @@ fun eraEra00(ut11: Double, ut12: Double): Angle { * * Greenwich apparent ST = GMST + equation of the equinoxes * - * The result is compatible with the IAU 2000 resolutions. For + * The result is compatible with the IAU 2000 resolutions. For * further details, see IERS Conventions 2003 and Capitaine et al. * (2002). * @@ -2015,7 +2015,7 @@ const val DEBIAS: Angle = -0.0068192 * ASEC2RAD const val DRA0: Angle = -0.0146 * ASEC2RAD /** - * Frame bias components of IAU 2000 precession-nutation models; part + * Frame bias components of IAU 2000 precession-nutation models; part * of the Mathews-Herring-Buffett (MHB2000) nutation series, with * additions. * @@ -2273,7 +2273,7 @@ fun eraTpxev(v: StarDirectionCosines, v0: TangentPointDirectionCosines): Tangent /** * This function forms three Euler angles which implement general - * precession from epoch J2000.0, using the IAU 2006 model. Frame + * precession from epoch J2000.0, using the IAU 2006 model. Frame * bias (the offset between ICRS and mean J2000.0) is included. */ fun eraPb06(tt1: Double, tt2: Double): EulerAngles { @@ -3430,7 +3430,7 @@ fun eraAtco13( pmRA: Angle, pmDEC: Angle, parallax: Angle, rv: Velocity, utc1: Double, utc2: Double, dut1: Double, elong: Angle, phi: Angle, hm: Double, xp: Angle, yp: Angle, - phpa: Double, tc: Temperature, rh: Double, wl: Double, + phpa: Pressure, tc: Temperature, rh: Double, wl: Double, ): Pair { // Star-independent astrometry parameters. val (astrom, eo) = eraApco13(utc1, utc2, dut1, elong, phi, hm, xp, yp, phpa, tc, rh, wl) @@ -3676,3 +3676,145 @@ private fun eraStarpv(pv: PositionAndVelocity): PositionAndVelocity { return PositionAndVelocity(pv.position, v) } + +private const val SELMIN = 0.05 + +/** + * Quick observed place to CIRS, given the star-independent astrometry parameters. + * + * Use of this method is appropriate when efficiency is important and + * where many star positions are all to be transformed for one date. + * The star-independent astrometry parameters can be obtained by + * calling [eraApio13] or [eraApco13]. + * + * @return CIRS right ascension (CIO-based, radians) and CIRS declination (radians). + */ +fun eraAtoiq(type: Char, obs1: Angle, obs2: Angle, astrom: AstrometryParameters): DoubleArray { + val c = type.uppercaseChar() + val sphi = astrom.sphi + val cphi = astrom.cphi + + var c1 = obs1 + val c2 = obs2 + + val xaeo: Double + val yaeo: Double + val zaeo: Double + + if (c == 'A') { + val ce = sin(c2) + xaeo = -cos(c1) * ce + yaeo = sin(c1) * ce + zaeo = cos(c2) + } else { + // If RA,Dec, convert to HA,Dec. + if (c == 'R') c1 = astrom.eral - c1 + + // To Cartesian -HA,Dec. + val v = eraS2c(-c1, c2) + val xmhdo = v[0] + val ymhdo = v[1] + val zmhdo = v[2] + + // To Cartesian Az,El (S=0,E=90). + xaeo = sphi * xmhdo - cphi * zmhdo + yaeo = ymhdo + zaeo = cphi * xmhdo + sphi * zmhdo + } + + // Azimuth (S=0,E=90). + val az = if (xaeo != 0.0 || yaeo != 0.0) atan2(yaeo, xaeo) else 0.0 + + // Sine of observed ZD, and observed ZD. + val sz = sqrt(xaeo * xaeo + yaeo * yaeo) + val zdo = atan2(sz, zaeo) + + // Fast algorithm using two constant model. + val refa = astrom.refa + val refb = astrom.refb + val tz = sz / (if (zaeo > SELMIN) zaeo else SELMIN) + val dref = (refa + refb * tz * tz) * tz + val zdt = zdo + dref + + // To Cartesian Az,ZD. + val ce = sin(zdt) + val xaet = cos(az) * ce + val yaet = sin(az) * ce + val zaet = cos(zdt) + + // Cartesian Az,ZD to Cartesian -HA,Dec. + val xmhda = sphi * xaet + cphi * zaet + val ymhda = yaet + val zmhda = -cphi * xaet + sphi * zaet + + // Diurnal aberration. + val f = (1.0 + astrom.diurab * ymhda) + val xhd = f * xmhda + val yhd = f * (ymhda - astrom.diurab) + val zhd = f * zmhda + + // Polar motion. + val sx = sin(astrom.xpl) + val cx = cos(astrom.xpl) + val sy = sin(astrom.ypl) + val cy = cos(astrom.ypl) + val v0 = cx * xhd + sx * sy * yhd - sx * cy * zhd + val v1 = cy * yhd + sy * zhd + val v2 = sx * xhd - cx * sy * yhd + cx * cy * zhd + + // To spherical -HA,Dec. + val (hma, di) = eraC2s(v0, v1, v2) + + // Right ascension. + return doubleArrayOf((astrom.eral + hma).normalized, di) +} + +/** + * Observed place at a groundbased site to to ICRS astrometric RA,Dec. + * The caller supplies UTC, site coordinates, ambient air conditions + * and observing wavelength. + * + * "Observed" Az,ZD means the position that would be seen by a + * perfect geodetically aligned theodolite. (Zenith distance is + * used rather than altitude in order to reflect the fact that no + * allowance is made for depression of the horizon.) + * + * Only the first character of the type argument is significant. + * "R" or "r" indicates that [obs1] and [obs2] are the observed right + * ascension (CIO-based) and declination; "H" or "h" indicates + * that they are hour angle (west +ve) and declination; anything + * else ("A" or "a" is recommended) indicates that [obs1] and [obs2] are + * azimuth (north zero, east 90 deg) and zenith distance. + * + * @param type Type of coordinates - "R", "H" or "A" (Notes 1,2) + * @param obs1 Observed Az, HA or RA (radians; Az is N=0,E=90) + * @param obs2 Observed ZD or Dec (radians) + * @param utc1 UTC as a 2-part... + * @param utc2 ...Julian Date + * @param dut1 UT1-UTC (seconds) + * @param elong Longitude (radians, east +ve) + * @param phi Latitude (geodetic, radians) + * @param hm Height above ellipsoid (m, geodetic) + * @param xp Polar motion coordinates (radians) + * @param yp Polar motion coordinates (radians) + * @param phpa Pressure at the observer (hPa = mBar) + * @param tc Ambient temperature at the observer (deg C) + * @param rh Relative humidity at the observer (range 0-1) + * @param wl Wavelength (micrometers) + * + * @return ICRS astrometric RA,Dec (radians) + */ +fun eraAtoc13( + type: Char, obs1: Angle, obs2: Angle, + utc1: Double, utc2: Double, dut1: Double, + elong: Angle, phi: Angle, hm: Double, + xp: Angle, yp: Angle, + phpa: Pressure, tc: Temperature, rh: Double, wl: Double +): DoubleArray { + // Star-independent astrometry parameters. + val (astrom, eo) = eraApco13(utc1, utc2, dut1, elong, phi, hm, xp, yp, phpa, tc, rh, wl) + // Transform observed to CIRS. + val (ri, di) = eraAtoiq(type, obs1, obs2, astrom) + // Transform CIRS to ICRS. + return eraAticq(ri, di, astrom) +} diff --git a/nebulosa-erfa/src/test/kotlin/ErfaTest.kt b/nebulosa-erfa/src/test/kotlin/ErfaTest.kt index 73b0a4558..8dad7452c 100644 --- a/nebulosa-erfa/src/test/kotlin/ErfaTest.kt +++ b/nebulosa-erfa/src/test/kotlin/ErfaTest.kt @@ -1118,5 +1118,38 @@ class ErfaTest : StringSpec() { pv.velocity[1] shouldBe (-0.6253919754414777970e-2 plusOrMinus 1e-15) pv.velocity[2] shouldBe (0.1189353714588109341e-1 plusOrMinus 1e-13) } + "eraAtoiq" { + val astrom = + eraApio13(2456384.5, 0.969254051, 0.1550675, -0.527800806, -1.2345856, 2738.0, 2.47230737e-7, 1.82640464e-6, 731.0, 12.8, 0.59, 0.55) + + with(eraAtoiq('R', 2.710085107986886201, 0.1717653435758265198, astrom)) { + this[0] shouldBe (2.710121574447540810 plusOrMinus 1e-12) + this[1] shouldBe (0.17293718391166087785 plusOrMinus 1e-12) + } + with(eraAtoiq('H', -0.09247619879782006106, 0.1717653435758265198, astrom)) { + this[0] shouldBe (2.710121574448138676 plusOrMinus 1e-12) + this[1] shouldBe (0.1729371839116608778 plusOrMinus 1e-12) + } + with(eraAtoiq('A', 0.09233952224794989993, 1.407758704513722461, astrom)) { + this[0] shouldBe (2.710121574448138676 plusOrMinus 1e-12) + this[1] shouldBe (0.1729371839116608781 plusOrMinus 1e-12) + } + } + "eraAtoc13" { + // @formatter:off + with(eraAtoc13('R', 2.710085107986886201, 0.1717653435758265198, 2456384.5, 0.969254051, 0.1550675, -0.527800806, -1.2345856, 2738.0, 2.47230737e-7, 1.82640464e-6, 731.0, 12.8, 0.59, 0.55)) { + this[0] shouldBe (2.709956744659136129 plusOrMinus 1e-12) + this[1] shouldBe (0.1741696500898471362 plusOrMinus 1e-12) + } + with(eraAtoc13('H', -0.09247619879782006106, 0.1717653435758265198, 2456384.5, 0.969254051, 0.1550675, -0.527800806, -1.2345856, 2738.0, 2.47230737e-7, 1.82640464e-6, 731.0, 12.8, 0.59, 0.55)) { + this[0] shouldBe (2.709956744659734086 plusOrMinus 1e-12) + this[1] shouldBe (0.1741696500898471362 plusOrMinus 1e-12) + } + with(eraAtoc13('A', 0.09233952224794989993, 1.407758704513722461, 2456384.5, 0.969254051, 0.1550675, -0.527800806, -1.2345856, 2738.0, 2.47230737e-7, 1.82640464e-6, 731.0, 12.8, 0.59, 0.55)) { + this[0] shouldBe (2.709956744659734086 plusOrMinus 1e-12) + this[1] shouldBe (0.1741696500898471366 plusOrMinus 1e-12) + } + // @formatter:on + } } } diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsFormat.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsFormat.kt index 95e648f7a..fef1fce0e 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsFormat.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsFormat.kt @@ -5,12 +5,14 @@ import nebulosa.image.format.* import nebulosa.io.* import nebulosa.log.loggerFor import okio.Buffer +import okio.BufferedSource import okio.Sink import java.io.EOFException import kotlin.math.max data object FitsFormat : ImageFormat { + const val SIGNATURE = "SIMPLE" const val BLOCK_SIZE = 2880 @JvmStatic @@ -20,6 +22,9 @@ data object FitsFormat : ImageFormat { return max(0L, remainingByteCount) } + @JvmStatic + fun BufferedSource.readSignature() = readString(6L, Charsets.US_ASCII) + fun isImageHdu(header: ReadableHeader) = header.getBoolean(FitsKeyword.SIMPLE) || header.getStringOrNull(FitsKeyword.XTENSION) == "IMAGE" @@ -66,8 +71,11 @@ data object FitsFormat : ImageFormat { val numberOfChannels = header.numberOfChannels val bitpix = header.bitpix val position = source.position + val rangeMin = header.getFloat(FitsKeyword.DATAMIN, 0f) + val rangeMax = header.getFloat(FitsKeyword.DATAMAX, 1f) + val range = rangeMin..rangeMax - val data = SeekableSourceImageData(source, position, width, height, numberOfChannels, bitpix) + val data = SeekableSourceImageData(source, position, width, height, numberOfChannels, bitpix, range) val skipBytes = computeRemainingBytesToSkip(data.totalSizeInBytes) if (skipBytes > 0L) source.seek(position + data.totalSizeInBytes + skipBytes) @@ -99,10 +107,11 @@ data object FitsFormat : ImageFormat { return hdus } - fun writeHeader(header: ReadableHeader, sink: Sink) { + fun writeHeader(header: ReadableHeader, bitpix: Bitpix, sink: Sink) { Buffer().use { buffer -> for (card in header) { - buffer.writeString(card.formatted(), Charsets.US_ASCII) + if (card.key == bitpix.key) buffer.writeCard(bitpix) + else buffer.writeCard(card) } if (header.last().key != FitsHeaderCard.END.key) { @@ -115,8 +124,7 @@ data object FitsFormat : ImageFormat { } } - fun writeImageData(data: ImageData, header: ReadableHeader, sink: Sink) { - val bitpix = header.bitpix + fun writeImageData(data: ImageData, bitpix: Bitpix, sink: Sink) { val channels = arrayOf(data.red, data.green, data.blue) var byteCount = 0L @@ -141,11 +149,15 @@ data object FitsFormat : ImageFormat { } } - override fun write(sink: Sink, hdus: Iterable>) { + override fun write(sink: Sink, hdus: Iterable>, modifier: ImageModifier) { + val bitpix = modifier.bitpix() + for (hdu in hdus) { if (hdu is ImageHdu) { - writeHeader(hdu.header, sink) - writeImageData(hdu.data, hdu.header, sink) + with(bitpix ?: hdu.header.bitpix) { + writeHeader(hdu.header, this, sink) + writeImageData(hdu.data, this, sink) + } } } } @@ -174,5 +186,10 @@ data object FitsFormat : ImageFormat { } } + @JvmStatic + private fun Buffer.writeCard(card: HeaderCard) { + writeString(card.formatted(), Charsets.US_ASCII) + } + @JvmStatic private val LOG = loggerFor() } diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHeader.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHeader.kt index 994041e49..9e0b83afd 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHeader.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHeader.kt @@ -143,7 +143,7 @@ open class FitsHeader : AbstractHeader { return true } - LOG.warn("[${key.key}] with unexpected value type. Expected $type, got ${key.valueType}") + LOG.warn("[${key.key}] with unexpected value type. Expected ${key.valueType}, got $type") return false } diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt index b829eb438..5409fe082 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHelper.kt @@ -2,10 +2,13 @@ package nebulosa.fits +import nebulosa.fits.FitsFormat.readSignature import nebulosa.image.format.ReadableHeader import nebulosa.io.SeekableSource import nebulosa.math.Angle import nebulosa.math.deg +import okio.buffer +import okio.source import java.io.File import java.nio.file.Path import java.time.Duration @@ -87,8 +90,10 @@ inline val ReadableHeader.instrument inline fun SeekableSource.fits() = Fits().also { it.read(this) } -inline fun String.fits() = FitsPath(this).also(FitsPath::read) - inline fun Path.fits() = FitsPath(this).also(FitsPath::read) inline fun File.fits() = FitsPath(this).also(FitsPath::read) + +inline fun File.isFits() = source().buffer().use { it.readSignature() == FitsFormat.SIGNATURE } + +inline fun Path.isFits() = source().buffer().use { it.readSignature() == FitsFormat.SIGNATURE } diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsPath.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsPath.kt index 765b4dedc..497ded614 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsPath.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsPath.kt @@ -1,20 +1,20 @@ package nebulosa.fits -import nebulosa.io.seekableSink -import nebulosa.io.seekableSource +import nebulosa.io.sink +import nebulosa.io.source import java.io.Closeable import java.io.File +import java.io.RandomAccessFile import java.nio.file.Path data class FitsPath(val path: Path) : Fits(), Closeable { - private val source = path.seekableSource() - private val sink = path.seekableSink() + private val file = RandomAccessFile(path.toFile(), "rw") + private val source = file.source() + private val sink = file.sink() constructor(file: File) : this(file.toPath()) - constructor(path: String) : this(Path.of(path)) - fun read() { read(source) } @@ -26,5 +26,6 @@ data class FitsPath(val path: Path) : Fits(), Closeable { override fun close() { source.close() sink.close() + file.close() } } diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/Modifiers.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/Modifiers.kt new file mode 100644 index 000000000..dd3ca55e9 --- /dev/null +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/Modifiers.kt @@ -0,0 +1,13 @@ +package nebulosa.fits + +import nebulosa.image.format.ImageModifier + +private data class WithBitpix(@JvmField val value: Bitpix) : ImageModifier.Element + +fun ImageModifier.bitpix(value: Bitpix) = then(WithBitpix(value)) + +fun ImageModifier.bitpix(): Bitpix? { + var bitpix: Bitpix? = null + foldIn { if (it is WithBitpix) bitpix = it.value } + return bitpix +} diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/SeekableSourceImageData.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/SeekableSourceImageData.kt index 4a7fc10ae..8733dde85 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/SeekableSourceImageData.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/SeekableSourceImageData.kt @@ -4,8 +4,10 @@ import nebulosa.fits.FitsFormat.readPixel import nebulosa.image.format.ImageChannel import nebulosa.image.format.ImageData import nebulosa.io.SeekableSource +import nebulosa.log.loggerFor import okio.Buffer import okio.Sink +import kotlin.math.max import kotlin.math.min @Suppress("NOTHING_TO_INLINE") @@ -16,6 +18,7 @@ internal data class SeekableSourceImageData( override val height: Int, override val numberOfChannels: Int, private val bitpix: Bitpix, + private val range: ClosedFloatingPointRange, ) : ImageData { @JvmField internal val channelSizeInBytes = (numberOfPixels * bitpix.byteLength).toLong() @@ -72,6 +75,9 @@ internal data class SeekableSourceImageData( var pos = 0 Buffer().use { buffer -> + var min = Float.MAX_VALUE + var max = Float.MIN_VALUE + while (remainingPixels > 0) { var n = min(PIXEL_COUNT, remainingPixels) val byteCount = n * bitpix.byteLength.toLong() @@ -84,11 +90,26 @@ internal data class SeekableSourceImageData( n = (size / bitpix.byteLength).toInt() repeat(n) { - output[pos++] = buffer.readPixel(bitpix) + val pixel = buffer.readPixel(bitpix) + if (pixel < min) min = pixel + if (pixel > max) max = pixel + output[pos++] = pixel } remainingPixels -= n } + + if (min < 0f || max > 1f) { + val rangeMin = min(range.start, min) + val rangeMax = max(range.endInclusive, max) + val rangeDelta = rangeMax - rangeMin + + LOG.info("rescaling [{}, {}] to [0, 1]. channel={}, delta={}", rangeMin, rangeMax, channel, rangeDelta) + + for (i in output.indices) { + output[i] = (output[i] - rangeMin) / rangeDelta + } + } } } @@ -123,5 +144,7 @@ internal data class SeekableSourceImageData( companion object { const val PIXEL_COUNT = 64 + + @JvmStatic private val LOG = loggerFor() } } diff --git a/nebulosa-fits/src/test/kotlin/FitsFormatTest.kt b/nebulosa-fits/src/test/kotlin/FitsFormatTest.kt new file mode 100644 index 000000000..081321c31 --- /dev/null +++ b/nebulosa-fits/src/test/kotlin/FitsFormatTest.kt @@ -0,0 +1,14 @@ +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import nebulosa.fits.isFits +import nebulosa.test.AbstractFitsAndXisfTest + +class FitsFormatTest : AbstractFitsAndXisfTest() { + + init { + "should be fits format" { + NGC3344_COLOR_8_FITS.isFits().shouldBeTrue() + M82_COLOR_16_XISF.isFits().shouldBeFalse() + } + } +} diff --git a/nebulosa-fits/src/test/kotlin/FitsReadTest.kt b/nebulosa-fits/src/test/kotlin/FitsReadTest.kt index ce38e8d58..ca4e1d328 100644 --- a/nebulosa-fits/src/test/kotlin/FitsReadTest.kt +++ b/nebulosa-fits/src/test/kotlin/FitsReadTest.kt @@ -10,70 +10,70 @@ class FitsReadTest : AbstractFitsAndXisfTest() { init { "mono:8-bit" { - val hdu = NGC3344_MONO_8_FITS.fits().filterIsInstance().first() + val hdu = closeAfterEach(NGC3344_MONO_8_FITS.fits()).filterIsInstance().first() hdu.width shouldBeExactly 256 hdu.height shouldBeExactly 256 hdu.numberOfChannels shouldBeExactly 1 hdu.header.bitpix shouldBe Bitpix.BYTE } "mono:16-bit" { - val hdu = NGC3344_MONO_16_FITS.fits().filterIsInstance().first() + val hdu = closeAfterEach(NGC3344_MONO_16_FITS.fits()).filterIsInstance().first() hdu.width shouldBeExactly 256 hdu.height shouldBeExactly 256 hdu.numberOfChannels shouldBeExactly 1 hdu.header.bitpix shouldBe Bitpix.SHORT } "mono:32-bit" { - val hdu = NGC3344_MONO_32_FITS.fits().filterIsInstance().first() + val hdu = closeAfterEach(NGC3344_MONO_32_FITS.fits()).filterIsInstance().first() hdu.width shouldBeExactly 256 hdu.height shouldBeExactly 256 hdu.numberOfChannels shouldBeExactly 1 hdu.header.bitpix shouldBe Bitpix.INTEGER } "mono:32-bit floating-point" { - val hdu = NGC3344_MONO_F32_FITS.fits().filterIsInstance().first() + val hdu = closeAfterEach(NGC3344_MONO_F32_FITS.fits()).filterIsInstance().first() hdu.width shouldBeExactly 256 hdu.height shouldBeExactly 256 hdu.numberOfChannels shouldBeExactly 1 hdu.header.bitpix shouldBe Bitpix.FLOAT } "mono:64-bit floating-point" { - val hdu = NGC3344_MONO_F64_FITS.fits().filterIsInstance().first() + val hdu = closeAfterEach(NGC3344_MONO_F64_FITS.fits()).filterIsInstance().first() hdu.width shouldBeExactly 256 hdu.height shouldBeExactly 256 hdu.numberOfChannels shouldBeExactly 1 hdu.header.bitpix shouldBe Bitpix.DOUBLE } "color:8-bit" { - val hdu = NGC3344_COLOR_8_FITS.fits().filterIsInstance().first() + val hdu = closeAfterEach(NGC3344_COLOR_8_FITS.fits()).filterIsInstance().first() hdu.width shouldBeExactly 256 hdu.height shouldBeExactly 256 hdu.numberOfChannels shouldBeExactly 4 hdu.header.bitpix shouldBe Bitpix.BYTE } "color:16-bit" { - val hdu = NGC3344_COLOR_16_FITS.fits().filterIsInstance().first() + val hdu = closeAfterEach(NGC3344_COLOR_16_FITS.fits()).filterIsInstance().first() hdu.width shouldBeExactly 256 hdu.height shouldBeExactly 256 hdu.numberOfChannels shouldBeExactly 4 hdu.header.bitpix shouldBe Bitpix.SHORT } "color:32-bit" { - val hdu = NGC3344_COLOR_32_FITS.fits().filterIsInstance().first() + val hdu = closeAfterEach(NGC3344_COLOR_32_FITS.fits()).filterIsInstance().first() hdu.width shouldBeExactly 256 hdu.height shouldBeExactly 256 hdu.numberOfChannels shouldBeExactly 4 hdu.header.bitpix shouldBe Bitpix.INTEGER } "color:32-bit floating-point" { - val hdu = NGC3344_COLOR_F32_FITS.fits().filterIsInstance().first() + val hdu = closeAfterEach(NGC3344_COLOR_F32_FITS.fits()).filterIsInstance().first() hdu.width shouldBeExactly 256 hdu.height shouldBeExactly 256 hdu.numberOfChannels shouldBeExactly 4 hdu.header.bitpix shouldBe Bitpix.FLOAT } "color:64-bit floating-point" { - val hdu = NGC3344_COLOR_F64_FITS.fits().filterIsInstance().first() + val hdu = closeAfterEach(NGC3344_COLOR_F64_FITS.fits()).filterIsInstance().first() hdu.width shouldBeExactly 256 hdu.height shouldBeExactly 256 hdu.numberOfChannels shouldBeExactly 4 diff --git a/nebulosa-fits/src/test/kotlin/FitsWriteTest.kt b/nebulosa-fits/src/test/kotlin/FitsWriteTest.kt index f455b85c5..3f560ff36 100644 --- a/nebulosa-fits/src/test/kotlin/FitsWriteTest.kt +++ b/nebulosa-fits/src/test/kotlin/FitsWriteTest.kt @@ -11,7 +11,7 @@ class FitsWriteTest : AbstractFitsAndXisfTest() { init { "mono" { - val hdu0 = NGC3344_MONO_8_FITS.fits().filterIsInstance().first() + val hdu0 = closeAfterEach(NGC3344_MONO_8_FITS.fits()).filterIsInstance().first() val data = ByteArray(69120) FitsFormat.write(data.sink(), listOf(hdu0)) data.toByteString(2880, 66240).md5().hex() shouldBe "e1735e21c94dc49885fabc429406e573" diff --git a/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/IdentityGuideAlgorithm.kt b/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/IdentityGuideAlgorithm.kt index 61e7d8a0c..f7fae575e 100644 --- a/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/IdentityGuideAlgorithm.kt +++ b/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/IdentityGuideAlgorithm.kt @@ -6,5 +6,5 @@ class IdentityGuideAlgorithm(override val axis: GuideAxis) : GuideAlgorithm { override fun compute(input: Double) = input - override fun reset() {} + override fun reset() = Unit } diff --git a/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/RandomDither.kt b/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/RandomDither.kt index c24877604..e4fb17c55 100644 --- a/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/RandomDither.kt +++ b/nebulosa-guiding-internal/src/main/kotlin/nebulosa/guiding/internal/RandomDither.kt @@ -10,7 +10,7 @@ class RandomDither(private val random: Random = Random.Default) : Dither { return doubleArrayOf(ra, dec) } - override fun reset() {} + override fun reset() = Unit companion object { diff --git a/nebulosa-guiding-phd2/src/main/kotlin/nebulosa/guiding/phd2/PHD2Guider.kt b/nebulosa-guiding-phd2/src/main/kotlin/nebulosa/guiding/phd2/PHD2Guider.kt index 2cb26cef0..81c33ab44 100644 --- a/nebulosa-guiding-phd2/src/main/kotlin/nebulosa/guiding/phd2/PHD2Guider.kt +++ b/nebulosa-guiding-phd2/src/main/kotlin/nebulosa/guiding/phd2/PHD2Guider.kt @@ -34,7 +34,7 @@ class PHD2Guider(private val client: PHD2Client) : Guider, PHD2EventListener { } override val isSettling - get() = settling.get() + get() = !settling.get() init { client.registerListener(this) diff --git a/nebulosa-image-format/src/main/kotlin/nebulosa/image/format/ImageFormat.kt b/nebulosa-image-format/src/main/kotlin/nebulosa/image/format/ImageFormat.kt index d24b6db98..891a4d348 100644 --- a/nebulosa-image-format/src/main/kotlin/nebulosa/image/format/ImageFormat.kt +++ b/nebulosa-image-format/src/main/kotlin/nebulosa/image/format/ImageFormat.kt @@ -7,5 +7,5 @@ interface ImageFormat { fun read(source: SeekableSource): List> - fun write(sink: Sink, hdus: Iterable>) + fun write(sink: Sink, hdus: Iterable>, modifier: ImageModifier = ImageModifier) } diff --git a/nebulosa-image-format/src/main/kotlin/nebulosa/image/format/ImageModifier.kt b/nebulosa-image-format/src/main/kotlin/nebulosa/image/format/ImageModifier.kt new file mode 100644 index 000000000..ebd5cb818 --- /dev/null +++ b/nebulosa-image-format/src/main/kotlin/nebulosa/image/format/ImageModifier.kt @@ -0,0 +1,44 @@ +package nebulosa.image.format + +// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Modifier.kt + +sealed interface ImageModifier { + + fun foldIn(operation: (Element) -> Unit) + + fun foldOut(operation: (Element) -> Unit) + + infix fun then(other: ImageModifier): ImageModifier = if (other === ImageModifier) this else Combined(this, other) + + interface Element : ImageModifier { + + override fun foldIn(operation: (Element) -> Unit) = operation(this) + + override fun foldOut(operation: (Element) -> Unit) = operation(this) + } + + private data class Combined( + private val outer: ImageModifier, + private val inner: ImageModifier, + ) : ImageModifier { + + override fun foldIn(operation: (Element) -> Unit) { + outer.foldIn(operation) + inner.foldIn(operation) + } + + override fun foldOut(operation: (Element) -> Unit) { + inner.foldOut(operation) + outer.foldOut(operation) + } + } + + companion object : ImageModifier { + + override fun foldIn(operation: (Element) -> Unit) = Unit + + override fun foldOut(operation: (Element) -> Unit) = Unit + + override fun then(other: ImageModifier) = other + } +} diff --git a/nebulosa-image/src/main/kotlin/nebulosa/image/Image.kt b/nebulosa-image/src/main/kotlin/nebulosa/image/Image.kt index 4e2a161aa..eae28c6ec 100644 --- a/nebulosa-image/src/main/kotlin/nebulosa/image/Image.kt +++ b/nebulosa-image/src/main/kotlin/nebulosa/image/Image.kt @@ -11,8 +11,6 @@ import nebulosa.image.format.* import okio.Sink import java.awt.color.ColorSpace import java.awt.image.* -import kotlin.math.max -import kotlin.math.min @Suppress("NOTHING_TO_INLINE") class Image internal constructor( @@ -178,8 +176,8 @@ class Image internal constructor( } } - fun writeTo(sink: Sink, format: ImageFormat) { - format.write(sink, listOf(hdu)) + fun writeTo(sink: Sink, format: ImageFormat, modifier: ImageModifier = ImageModifier) { + format.write(sink, listOf(hdu), modifier) } /** @@ -215,28 +213,6 @@ class Image internal constructor( return image } - fun canLoad(hdu: ImageHdu): Boolean { - return hdu.width == width && hdu.height == height && hdu.isMono == mono - } - - fun canLoad(image: ImageRepresentation): Boolean { - return canLoad(image.filterIsInstance().first()) - } - - fun load(image: ImageRepresentation): Image? { - return load(image.filterIsInstance().first()) - } - - fun load(image: Image): Image? { - return load(image.hdu) - } - - fun load(hdu: ImageHdu): Image? { - if (!canLoad(hdu)) return null - load(this, hdu, false) - return this - } - public override fun clone() = if (mono) mono() else color() fun transform(vararg algorithms: TransformAlgorithm) = algorithms.transform(this) @@ -291,59 +267,6 @@ class Image internal constructor( } } - @JvmStatic - private fun load(image: Image, hdu: ImageHdu, debayer: Boolean) { - hdu.data.readChannelTo(ImageChannel.RED, image.red) - - if (!image.mono) { - if (debayer) { - image.debayer() - } else { - hdu.data.readChannelTo(ImageChannel.GREEN, image.green) - hdu.data.readChannelTo(ImageChannel.BLUE, image.blue) - } - } - } - - @JvmStatic - private fun load(image: Image, debayer: Boolean) { - fun rescaling() { - for (p in 0 until image.numberOfChannels) { - val minMax = floatArrayOf(Float.MAX_VALUE, Float.MIN_VALUE) - val plane = image.data[p] - - for (i in plane.indices) { - val k = plane[i] - minMax[0] = min(minMax[0], k) - minMax[1] = max(minMax[1], k) - } - - if (minMax[0] < 0f || minMax[1] > 1f) { - val k = minMax[1] - minMax[0] - - for (i in plane.indices) { - plane[i] = (plane[i] - minMax[0]) / k - } - } - } - } - - // TODO: DATA[i] = BZERO + BSCALE * DATA[i] - - // Mono. - if (image.mono) { - rescaling() - } else { - val bayer = CfaPattern.from(image.header) - - if (debayer && bayer != null) { - Debayer(bayer).transform(image) - } - - rescaling() - } - } - @JvmStatic fun open(bufferedImage: BufferedImage): Image { val header = FitsHeader() diff --git a/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/transformation/CfaPattern.kt b/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/transformation/CfaPattern.kt index b650c546f..c7b8e36dd 100644 --- a/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/transformation/CfaPattern.kt +++ b/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/transformation/CfaPattern.kt @@ -1,18 +1,22 @@ package nebulosa.image.algorithms.transformation +import nebulosa.fits.FitsHeaderCard +import nebulosa.fits.FitsKeyword import nebulosa.fits.cfaPattern +import nebulosa.image.format.HeaderCard import nebulosa.image.format.ImageChannel import nebulosa.image.format.ReadableHeader -enum class CfaPattern(private val pattern: Array>) { - RGGB(arrayOf(arrayOf(ImageChannel.RED, ImageChannel.GREEN), arrayOf(ImageChannel.GREEN, ImageChannel.BLUE))), - BGGR(arrayOf(arrayOf(ImageChannel.BLUE, ImageChannel.GREEN), arrayOf(ImageChannel.GREEN, ImageChannel.RED))), - GBRG(arrayOf(arrayOf(ImageChannel.GREEN, ImageChannel.BLUE), arrayOf(ImageChannel.RED, ImageChannel.GREEN))), - GRBG(arrayOf(arrayOf(ImageChannel.GREEN, ImageChannel.RED), arrayOf(ImageChannel.BLUE, ImageChannel.GREEN))), - GRGB(arrayOf(arrayOf(ImageChannel.GREEN, ImageChannel.RED), arrayOf(ImageChannel.GREEN, ImageChannel.BLUE))), - GBGR(arrayOf(arrayOf(ImageChannel.GREEN, ImageChannel.BLUE), arrayOf(ImageChannel.GREEN, ImageChannel.RED))), - RGBG(arrayOf(arrayOf(ImageChannel.RED, ImageChannel.GREEN), arrayOf(ImageChannel.BLUE, ImageChannel.GREEN))), - BGRG(arrayOf(arrayOf(ImageChannel.BLUE, ImageChannel.GREEN), arrayOf(ImageChannel.RED, ImageChannel.GREEN))); +enum class CfaPattern(private val pattern: Array>, code: String) : + HeaderCard by FitsHeaderCard.create(FitsKeyword.BAYERPAT, code) { + RGGB(arrayOf(arrayOf(ImageChannel.RED, ImageChannel.GREEN), arrayOf(ImageChannel.GREEN, ImageChannel.BLUE)), "RGGB"), + BGGR(arrayOf(arrayOf(ImageChannel.BLUE, ImageChannel.GREEN), arrayOf(ImageChannel.GREEN, ImageChannel.RED)), "BGGR"), + GBRG(arrayOf(arrayOf(ImageChannel.GREEN, ImageChannel.BLUE), arrayOf(ImageChannel.RED, ImageChannel.GREEN)), "GBRG"), + GRBG(arrayOf(arrayOf(ImageChannel.GREEN, ImageChannel.RED), arrayOf(ImageChannel.BLUE, ImageChannel.GREEN)), "GRBG"), + GRGB(arrayOf(arrayOf(ImageChannel.GREEN, ImageChannel.RED), arrayOf(ImageChannel.GREEN, ImageChannel.BLUE)), "GRGB"), + GBGR(arrayOf(arrayOf(ImageChannel.GREEN, ImageChannel.BLUE), arrayOf(ImageChannel.GREEN, ImageChannel.RED)), "GBGR"), + RGBG(arrayOf(arrayOf(ImageChannel.RED, ImageChannel.GREEN), arrayOf(ImageChannel.BLUE, ImageChannel.GREEN)), "RGBG"), + BGRG(arrayOf(arrayOf(ImageChannel.BLUE, ImageChannel.GREEN), arrayOf(ImageChannel.RED, ImageChannel.GREEN)), "BGRG"); operator fun get(y: Int, x: Int) = pattern[y][x] diff --git a/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/transformation/ScreenTransformFunction.kt b/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/transformation/ScreenTransformFunction.kt index 8a09f240b..8c9737a48 100644 --- a/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/transformation/ScreenTransformFunction.kt +++ b/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/transformation/ScreenTransformFunction.kt @@ -23,7 +23,7 @@ data class ScreenTransformFunction( companion object { - @JvmStatic val EMPTY = Parameters() + @JvmStatic val DEFAULT = Parameters() } } diff --git a/nebulosa-image/src/test/kotlin/FitsTransformAlgorithmTest.kt b/nebulosa-image/src/test/kotlin/FitsTransformAlgorithmTest.kt index 855fc06ea..631ae94c8 100644 --- a/nebulosa-image/src/test/kotlin/FitsTransformAlgorithmTest.kt +++ b/nebulosa-image/src/test/kotlin/FitsTransformAlgorithmTest.kt @@ -1,8 +1,6 @@ import io.kotest.matchers.booleans.shouldBeFalse import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.ints.shouldBeExactly -import io.kotest.matchers.nulls.shouldBeNull -import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import nebulosa.fits.fits import nebulosa.image.Image @@ -121,15 +119,6 @@ class FitsTransformAlgorithmTest : AbstractFitsAndXisfTest() { mImage.transform(AutoScreenTransformFunction) mImage.save("fits-mono-auto-stf").second shouldBe "e17cfc29c3b343409cd8617b6913330e" } - "!mono:reload" { - val mImage0 = Image.open(NGC3344_MONO_8_FITS.fits()) - - val mImage1 = Image.open(NGC3344_MONO_8_FITS.fits()) - mImage1.transform(Invert) - - mImage0.load(mImage1.hdu) - mImage0.save("fits-mono-reload").second shouldBe "6e94463bb5b9561de1f0ee0a154db53e" - } "color:raw" { val mImage = Image.open(NGC3344_COLOR_32_FITS.fits()) mImage.save("fits-color-raw").second shouldBe "18fb83e240bc7a4cbafbc1aba2741db6" @@ -287,17 +276,5 @@ class FitsTransformAlgorithmTest : AbstractFitsAndXisfTest() { val nImage = mImage.transform(AutoScreenTransformFunction) nImage.save("fits-color-no-debayer").second shouldBe "958ccea020deec1f0c075042a9ba37c3" } - "color:reload" { - val mImage0 = Image.open(NGC3344_COLOR_32_FITS.fits()) - var mImage1 = Image.open(DEBAYER_FITS.fits()) - - mImage1.load(mImage0.hdu).shouldNotBeNull() - mImage1.save("fits-color-reload").second shouldBe "18fb83e240bc7a4cbafbc1aba2741db6" - - mImage1 = Image.open(DEBAYER_FITS.fits(), false) - - mImage1.load(mImage0.hdu).shouldBeNull() - mImage0.load(mImage1.hdu).shouldBeNull() - } } } diff --git a/nebulosa-image/src/test/kotlin/XisfTransformAlgorithmTest.kt b/nebulosa-image/src/test/kotlin/XisfTransformAlgorithmTest.kt index a50872f8a..d42c91837 100644 --- a/nebulosa-image/src/test/kotlin/XisfTransformAlgorithmTest.kt +++ b/nebulosa-image/src/test/kotlin/XisfTransformAlgorithmTest.kt @@ -1,8 +1,6 @@ import io.kotest.matchers.booleans.shouldBeFalse import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.ints.shouldBeExactly -import io.kotest.matchers.nulls.shouldBeNull -import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import nebulosa.image.Image import nebulosa.image.algorithms.transformation.* @@ -121,15 +119,6 @@ class XisfTransformAlgorithmTest : AbstractFitsAndXisfTest() { mImage.transform(AutoScreenTransformFunction) mImage.save("xisf-mono-auto-stf").second shouldBe "9204a71df3770e8fe5ca49e3420eed72" } - "!mono:reload" { - val mImage0 = Image.open(M82_MONO_8_XISF.xisf()) - - val mImage1 = Image.open(M82_MONO_8_XISF.xisf()) - mImage1.transform(Invert) - - mImage0.load(mImage1.hdu) - mImage0.save("xisf-mono-reload").second shouldBe "6e94463bb5b9561de1f0ee0a154db53e" - } "color:raw" { val mImage = Image.open(M82_COLOR_32_XISF.xisf()) mImage.save("xisf-color-raw").second shouldBe "764e326cc5260d81f3761112ad6a1969" @@ -287,17 +276,5 @@ class XisfTransformAlgorithmTest : AbstractFitsAndXisfTest() { val nImage = mImage.transform(AutoScreenTransformFunction) nImage.save("xisf-color-no-debayer").second shouldBe "958ccea020deec1f0c075042a9ba37c3" } - "!color:reload" { - val mImage0 = Image.open(M82_COLOR_32_XISF.xisf()) - var mImage1 = Image.open(DEBAYER_FITS.xisf()) - - mImage1.load(mImage0.hdu).shouldNotBeNull() - mImage1.save("xisf-color-reload").second shouldBe "18fb83e240bc7a4cbafbc1aba2741db6" - - mImage1 = Image.open(DEBAYER_FITS.xisf(), false) - - mImage1.load(mImage0.hdu).shouldBeNull() - mImage0.load(mImage1.hdu).shouldBeNull() - } } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt index aff8daf11..1c6199ac5 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/INDIClient.kt @@ -10,7 +10,7 @@ import nebulosa.indi.client.device.cameras.SVBonyCamera import nebulosa.indi.client.device.cameras.SimCamera import nebulosa.indi.client.device.focusers.INDIFocuser import nebulosa.indi.client.device.mounts.INDIMount -import nebulosa.indi.client.device.mounts.IoptronV3Mount +import nebulosa.indi.client.device.rotators.INDIRotator import nebulosa.indi.client.device.wheels.INDIFilterWheel import nebulosa.indi.device.Device import nebulosa.indi.device.INDIDeviceProvider @@ -18,9 +18,8 @@ import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.filterwheel.FilterWheel import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.gps.GPS -import nebulosa.indi.device.guide.GuideOutput import nebulosa.indi.device.mount.Mount -import nebulosa.indi.device.thermometer.Thermometer +import nebulosa.indi.device.rotator.Rotator import nebulosa.indi.protocol.GetProperties import nebulosa.indi.protocol.INDIProtocol import nebulosa.indi.protocol.io.INDIConnection @@ -52,7 +51,7 @@ data class INDIClient(val connection: INDIConnection) : INDIDeviceProtocolHandle } override fun newMount(message: INDIProtocol, executable: String): Mount { - return MOUNTS[executable]?.create(this, message.device) ?: INDIMount(this, message.device) + return INDIMount(this, message.device) } override fun newFocuser(message: INDIProtocol): Focuser { @@ -63,6 +62,10 @@ data class INDIClient(val connection: INDIConnection) : INDIDeviceProtocolHandle return INDIFilterWheel(this, message.device) } + override fun newRotator(message: INDIProtocol): Rotator { + return INDIRotator(this, message.device) + } + override fun newGPS(message: INDIProtocol): GPS { return GPSDevice(this, message.device) } @@ -81,65 +84,8 @@ data class INDIClient(val connection: INDIConnection) : INDIDeviceProtocolHandle fireOnConnectionClosed() } - override fun cameras(): List { - return cameras.values.toList() - } - - override fun camera(name: String): Camera? { - return cameras[name] - } - - override fun mounts(): List { - return mounts.values.toList() - } - - override fun mount(name: String): Mount? { - return mounts[name] - } - - override fun focusers(): List { - return focusers.values.toList() - } - - override fun focuser(name: String): Focuser? { - return focusers[name] - } - - override fun wheels(): List { - return wheels.values.toList() - } - - override fun wheel(name: String): FilterWheel? { - return wheels[name] - } - - override fun gps(): List { - return gps.values.toList() - } - - override fun gps(name: String): GPS? { - return gps[name] - } - - override fun guideOutputs(): List { - return guideOutputs.values.toList() - } - - override fun guideOutput(name: String): GuideOutput? { - return guideOutputs[name] - } - - override fun thermometers(): List { - return thermometers.values.toList() - } - - override fun thermometer(name: String): Thermometer? { - return thermometers[name] - } - override fun close() { super.close() - connection.close() } @@ -156,12 +102,8 @@ data class INDIClient(val connection: INDIConnection) : INDIDeviceProtocolHandle "indi_simulator_guide" to SimCamera::class.java, ) - @JvmStatic private val MOUNTS = mapOf( - "indi_ioptronv3_telescope" to IoptronV3Mount::class.java, - ) - @JvmStatic - fun Class.create(handler: INDIClient, name: String): T { + private fun Class.create(handler: INDIClient, name: String): T { return getConstructor(INDIClient::class.java, String::class.java) .newInstance(handler, name) } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt index 4c02e94da..06051fcc8 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDevice.kt @@ -5,7 +5,7 @@ import nebulosa.indi.client.device.cameras.INDICamera import nebulosa.indi.device.* import nebulosa.indi.protocol.* import nebulosa.indi.protocol.Vector -import nebulosa.log.loggerFor +import okio.ByteString.Companion.encodeUtf8 import java.util.* internal abstract class INDIDevice : Device { @@ -15,7 +15,7 @@ internal abstract class INDIDevice : Device { override val properties = linkedMapOf>() override val messages = LinkedList() - override val id = UUID.randomUUID().toString() + override val id by lazy { name.encodeUtf8().md5().hex() } @Volatile override var connected = false protected set @@ -212,9 +212,4 @@ internal abstract class INDIDevice : Device { result = 31 * result + name.hashCode() return result } - - companion object { - - @JvmStatic private val LOG = loggerFor() - } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt index 0da39ca34..333c7fb28 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/INDIDeviceProtocolHandler.kt @@ -1,28 +1,15 @@ package nebulosa.indi.client.device -import nebulosa.indi.device.* +import nebulosa.indi.device.AbstractINDIDeviceProvider +import nebulosa.indi.device.Device +import nebulosa.indi.device.DeviceMessageReceived +import nebulosa.indi.device.MessageSender import nebulosa.indi.device.camera.Camera -import nebulosa.indi.device.camera.CameraAttached -import nebulosa.indi.device.camera.CameraDetached -import nebulosa.indi.device.camera.GuideHead import nebulosa.indi.device.filterwheel.FilterWheel -import nebulosa.indi.device.filterwheel.FilterWheelAttached -import nebulosa.indi.device.filterwheel.FilterWheelDetached import nebulosa.indi.device.focuser.Focuser -import nebulosa.indi.device.focuser.FocuserAttached -import nebulosa.indi.device.focuser.FocuserDetached import nebulosa.indi.device.gps.GPS -import nebulosa.indi.device.gps.GPSAttached -import nebulosa.indi.device.gps.GPSDetached -import nebulosa.indi.device.guide.GuideOutput -import nebulosa.indi.device.guide.GuideOutputAttached -import nebulosa.indi.device.guide.GuideOutputDetached import nebulosa.indi.device.mount.Mount -import nebulosa.indi.device.mount.MountAttached -import nebulosa.indi.device.mount.MountDetached -import nebulosa.indi.device.thermometer.Thermometer -import nebulosa.indi.device.thermometer.ThermometerAttached -import nebulosa.indi.device.thermometer.ThermometerDetached +import nebulosa.indi.device.rotator.Rotator import nebulosa.indi.protocol.DefTextVector import nebulosa.indi.protocol.DelProperty import nebulosa.indi.protocol.INDIProtocol @@ -34,20 +21,12 @@ import nebulosa.log.debug import nebulosa.log.loggerFor import java.util.concurrent.LinkedBlockingQueue -abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser, CloseConnectionListener { +abstract class INDIDeviceProtocolHandler : AbstractINDIDeviceProvider(), MessageSender, INDIProtocolParser, CloseConnectionListener { - @JvmField protected val cameras = HashMap(2) - @JvmField protected val mounts = HashMap(1) - @JvmField protected val wheels = HashMap(1) - @JvmField protected val focusers = HashMap(2) - @JvmField protected val gps = HashMap(2) - @JvmField protected val guideOutputs = HashMap(2) - @JvmField protected val thermometers = HashMap(2) private val messageReorderingQueue = LinkedBlockingQueue() private val notRegisteredDevices = HashSet() @Volatile private var protocolReader: INDIProtocolReader? = null private val messageQueueCounter = HashMap(2048) - private val handlers = LinkedHashSet() override val isClosed get() = protocolReader == null || !protocolReader!!.isRunning @@ -60,79 +39,9 @@ abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser, Cl protected abstract fun newFilterWheel(message: INDIProtocol): FilterWheel - protected abstract fun newGPS(message: INDIProtocol): GPS - - fun registerDeviceEventHandler(handler: DeviceEventHandler) { - handlers.add(handler) - } - - fun unregisterDeviceEventHandler(handler: DeviceEventHandler) { - handlers.remove(handler) - } - - fun fireOnEventReceived(event: DeviceEvent<*>) { - handlers.forEach { it.onEventReceived(event) } - } - - fun fireOnConnectionClosed() { - handlers.forEach { it.onConnectionClosed() } - } - - internal fun registerGPS(device: GPS) { - if (device.name !in gps) { - gps[device.name] = device - fireOnEventReceived(GPSAttached(device)) - } - } - - internal fun unregisterGPS(device: GPS) { - if (device.name in gps) { - gps.remove(device.name) - fireOnEventReceived(GPSDetached(device)) - } - } - - internal fun registerGuideHead(device: GuideHead) { - if (device.name !in cameras) { - cameras[device.name] = device - fireOnEventReceived(CameraAttached(device)) - } - } + protected abstract fun newRotator(message: INDIProtocol): Rotator - internal fun unregisterGuiderHead(device: GuideHead) { - if (device.name in cameras) { - cameras.remove(device.name) - fireOnEventReceived(CameraDetached(device)) - } - } - - internal fun registerGuideOutput(device: GuideOutput) { - if (device.name !in guideOutputs) { - guideOutputs[device.name] = device - fireOnEventReceived(GuideOutputAttached(device)) - } - } - - internal fun unregisterGuideOutput(device: GuideOutput) { - if (device.name in guideOutputs) { - guideOutputs.remove(device.name) - fireOnEventReceived(GuideOutputDetached(device)) - } - } - - internal fun registerThermometer(device: Thermometer) { - if (device.name !in thermometers) { - thermometers[device.name] = device - fireOnEventReceived(ThermometerAttached(device)) - } - } - - internal fun unregisterThermometer(device: Thermometer) { - if (device.name in thermometers) { - thermometers.remove(device.name) - fireOnEventReceived(ThermometerDetached(device)) - } - } + protected abstract fun newGPS(message: INDIProtocol): GPS open fun start() { if (protocolReader == null) { @@ -150,54 +59,26 @@ abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser, Cl } finally { protocolReader = null - for ((_, device) in cameras) { - device.close() - LOG.info("camera detached: {}", device.name) - fireOnEventReceived(CameraDetached(device)) - } - - for ((_, device) in mounts) { - device.close() - LOG.info("mount detached: {}", device.name) - fireOnEventReceived(MountDetached(device)) - } - - for ((_, device) in wheels) { - device.close() - LOG.info("filter wheel detached: {}", device.name) - fireOnEventReceived(FilterWheelDetached(device)) - } - - for ((_, device) in focusers) { - device.close() - LOG.info("focuser detached: {}", device.name) - fireOnEventReceived(FocuserDetached(device)) - } - - for ((_, device) in gps) { - device.close() - LOG.info("gps detached: {}", device.name) - fireOnEventReceived(GPSDetached(device)) - } - - cameras.clear() - mounts.clear() - wheels.clear() - focusers.clear() - gps.clear() - guideOutputs.clear() - thermometers.clear() + super.close() notRegisteredDevices.clear() messageQueueCounter.clear() messageReorderingQueue.clear() - handlers.clear() } } - fun findDeviceByName(name: String): Device? { - return cameras[name] ?: mounts[name] ?: wheels[name] - ?: focusers[name] ?: gps[name] + private fun takeMessageFromReorderingQueue(device: Device) { + if (messageReorderingQueue.isNotEmpty()) { + repeat(messageReorderingQueue.size) { + val queuedMessage = messageReorderingQueue.take() + + if (queuedMessage.device == device.name) { + handleMessage(queuedMessage) + } else { + messageReorderingQueue.offer(queuedMessage) + } + } + } } @Synchronized @@ -211,77 +92,63 @@ abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser, Cl var registered = false - fun takeMessageFromReorderingQueue(device: Device) { - if (messageReorderingQueue.isNotEmpty()) { - repeat(messageReorderingQueue.size) { - val queuedMessage = messageReorderingQueue.take() + if (executable in Camera.DRIVERS) { + registered = true - if (queuedMessage.device == device.name) { - handleMessage(queuedMessage) - } else { - messageReorderingQueue.offer(message) - } + with(newCamera(message, executable)) { + if (registerCamera(this)) { + takeMessageFromReorderingQueue(this) } } } - if (executable in Camera.DRIVERS) { + if (executable in Mount.DRIVERS) { registered = true - if (message.device !in cameras) { - val device = newCamera(message, executable) - cameras[message.device] = device - takeMessageFromReorderingQueue(device) - LOG.info("camera attached: {}", device.name) - fireOnEventReceived(CameraAttached(device)) + with(newMount(message, executable)) { + if (registerMount(this)) { + takeMessageFromReorderingQueue(this) + } } } - if (executable in Mount.DRIVERS) { + if (executable in FilterWheel.DRIVERS) { registered = true - if (message.device !in mounts) { - val device = newMount(message, executable) - mounts[message.device] = device - takeMessageFromReorderingQueue(device) - LOG.info("mount attached: {}", device.name) - fireOnEventReceived(MountAttached(device)) + with(newFilterWheel(message)) { + if (registerFilterWheel(this)) { + takeMessageFromReorderingQueue(this) + } } } - if (executable in FilterWheel.DRIVERS) { + if (executable in Focuser.DRIVERS) { registered = true - if (message.device !in wheels) { - val device = newFilterWheel(message) - wheels[message.device] = device - takeMessageFromReorderingQueue(device) - LOG.info("filter wheel attached: {}", device.name) - fireOnEventReceived(FilterWheelAttached(device)) + with(newFocuser(message)) { + if (registerFocuser(this)) { + takeMessageFromReorderingQueue(this) + } } } - if (executable in Focuser.DRIVERS) { + if (executable in Rotator.DRIVERS) { registered = true - if (message.device !in focusers) { - val device = newFocuser(message) - focusers[message.device] = device - takeMessageFromReorderingQueue(device) - LOG.info("focuser attached: {}", device.name) - fireOnEventReceived(FocuserAttached(device)) + with(newRotator(message)) { + if (registerRotator(this)) { + takeMessageFromReorderingQueue(this) + } } } if (executable in GPS.DRIVERS) { registered = true - if (message.device !in gps) { - val device = newGPS(message) - gps[message.device] = device - takeMessageFromReorderingQueue(device) - LOG.info("gps attached: {}", device.name) - fireOnEventReceived(GPSAttached(device)) + with(newGPS(message)) { + if (registerGPS(this)) { + takeMessageFromReorderingQueue(this) + } } } @@ -297,7 +164,7 @@ abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser, Cl when (message) { is Message -> { - val device = findDeviceByName(message.device) + val device = device(message.device) if (device == null) { val text = "[%s]: %s\n".format(message.timestamp, message.message) @@ -312,36 +179,17 @@ abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser, Cl } is DelProperty -> { if (message.name.isEmpty() && message.device.isNotEmpty()) { - val device = findDeviceByName(message.device) + val device = device(message.device) device?.close() when (device) { - is Camera -> { - fireOnEventReceived(CameraDetached(device)) - LOG.info("camera detached: {}", device.name) - cameras.remove(device.name) - } - is Mount -> { - fireOnEventReceived(MountDetached(device)) - LOG.info("mount detached: {}", device.name) - mounts.remove(device.name) - } - is FilterWheel -> { - fireOnEventReceived(FilterWheelDetached(device)) - LOG.info("filter wheel detached: {}", device.name) - wheels.remove(device.name) - } - is Focuser -> { - fireOnEventReceived(FocuserDetached(device)) - LOG.info("focuser detached: {}", device.name) - focusers.remove(device.name) - } - is GPS -> { - fireOnEventReceived(GPSDetached(device)) - LOG.info("gps detached: {}", device.name) - focusers.remove(device.name) - } + is Camera -> unregisterCamera(device) + is Mount -> unregisterMount(device) + is FilterWheel -> unregisterFilterWheel(device) + is Focuser -> unregisterFocuser(device) + is Rotator -> unregisterRotator(device) + is GPS -> unregisterGPS(device) } return @@ -355,7 +203,7 @@ abstract class INDIDeviceProtocolHandler : MessageSender, INDIProtocolParser, Cl return } - val device = findDeviceByName(message.device) + val device = device(message.device) if (device != null) { device.handleMessage(message) diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/INDICamera.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/INDICamera.kt index c633a3d7d..a97eba648 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/INDICamera.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/INDICamera.kt @@ -161,18 +161,22 @@ internal open class INDICamera( sender.fireOnEventReceived(CameraExposureAborted(this)) } else if (exposureState == PropertyState.OK && prevExposureState == PropertyState.BUSY) { sender.fireOnEventReceived(CameraExposureFinished(this)) - } else if (exposureState == PropertyState.ALERT && prevExposureState != PropertyState.ALERT) { + } else if (exposureState == PropertyState.ALERT) { sender.fireOnEventReceived(CameraExposureFailed(this)) } if (prevExposureState != exposureState) { - sender.fireOnEventReceived(CameraExposureStateChanged(this, prevExposureState)) + sender.fireOnEventReceived(CameraExposureStateChanged(this)) } } } "CCD_COOLER_POWER" -> { - coolerPower = message.first().value - sender.fireOnEventReceived(CameraCoolerPowerChanged(this)) + message.first().value.also { + if (it != coolerPower) { + coolerPower = it + sender.fireOnEventReceived(CameraCoolerPowerChanged(this)) + } + } } "CCD_TEMPERATURE" -> { if (message is DefNumberVector) { @@ -238,8 +242,6 @@ internal open class INDICamera( canPulseGuide = true sender.registerGuideOutput(this) - - LOG.info("guide output attached: {}", name) } else { val prevIsPulseGuiding = pulseGuiding pulseGuiding = message.isBusy @@ -258,9 +260,6 @@ internal open class INDICamera( if (!isGuideHead && message.name == "CCD2" && guideHead == null) { guideHead = GuideHeadCamera(this) sender.registerGuideHead(guideHead!!) - - LOG.info("guide head attached: {}", name) - return } } @@ -336,6 +335,11 @@ internal open class INDICamera( override fun startCapture(exposureTime: Duration) { sendNewSwitch("CCD_TRANSFER_FORMAT", "FORMAT_FITS" to true) + if (exposureState != PropertyState.IDLE) { + exposureState = PropertyState.IDLE + sender.fireOnEventReceived(CameraExposureStateChanged(this)) + } + val exposureInSeconds = exposureTime.toNanos() / NANO_TO_SECONDS if (this is GuideHead) { @@ -402,21 +406,18 @@ internal open class INDICamera( override fun close() { if (hasThermometer) { - sender.unregisterThermometer(this) hasThermometer = false - LOG.info("thermometer detached: {}", name) + sender.unregisterThermometer(this) } if (canPulseGuide) { - sender.unregisterGuideOutput(this) canPulseGuide = false - LOG.info("guide output detached: {}", name) + sender.unregisterGuideOutput(this) } if (guideHead != null) { guideHead?.also(sender::unregisterGuiderHead) guideHead = null - LOG.info("guide head detached: {}", name) } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SVBonyCamera.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SVBonyCamera.kt index f8e2f8f45..164996a34 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SVBonyCamera.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/cameras/SVBonyCamera.kt @@ -9,27 +9,15 @@ internal class SVBonyCamera( name: String, ) : INDICamera(provider, name) { - @Volatile private var legacyProperties = false - override fun handleMessage(message: INDIProtocol) { when (message) { is NumberVector<*> -> { when (message.name) { - "CCD_GAIN" -> { - legacyProperties = true - processGain(message, message["GAIN"]!!) - } - "CCD_OFFSET" -> { - legacyProperties = true - processOffset(message, message["OFFSET"]!!) - } "CCD_CONTROLS" -> { if ("Gain" in message) { - legacyProperties = false processGain(message, message["Gain"]!!) } if ("Offset" in message) { - legacyProperties = false processOffset(message, message["Offset"]!!) } } @@ -42,12 +30,10 @@ internal class SVBonyCamera( } override fun gain(value: Int) { - if (legacyProperties) sendNewNumber("CCD_GAIN", "GAIN" to value.toDouble()) - else sendNewNumber("CCD_CONTROLS", "Gain" to value.toDouble()) + sendNewNumber("CCD_CONTROLS", "Gain" to value.toDouble()) } override fun offset(value: Int) { - if (legacyProperties) sendNewNumber("CCD_OFFSET", "OFFSET" to value.toDouble()) - else sendNewNumber("CCD_CONTROLS", "Offset" to value.toDouble()) + sendNewNumber("CCD_CONTROLS", "Offset" to value.toDouble()) } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/focusers/INDIFocuser.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/focusers/INDIFocuser.kt index ce582ed44..82413ddf1 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/focusers/INDIFocuser.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/focusers/INDIFocuser.kt @@ -4,8 +4,6 @@ import nebulosa.indi.client.INDIClient import nebulosa.indi.client.device.INDIDevice import nebulosa.indi.device.firstOnSwitch import nebulosa.indi.device.focuser.* -import nebulosa.indi.device.thermometer.ThermometerAttached -import nebulosa.indi.device.thermometer.ThermometerDetached import nebulosa.indi.protocol.* // https://github.com/indilib/indi/blob/master/libs/indibase/indifocuser.cpp @@ -36,14 +34,12 @@ internal open class INDIFocuser( "FOCUS_ABORT_MOTION" -> { if (message is DefSwitchVector) { canAbort = true - sender.fireOnEventReceived(FocuserCanAbortChanged(this)) } } "FOCUS_REVERSE_MOTION" -> { if (message is DefSwitchVector) { canReverse = true - sender.fireOnEventReceived(FocuserCanReverseChanged(this)) } @@ -124,14 +120,17 @@ internal open class INDIFocuser( } "FOCUS_TEMPERATURE" -> { - if (message is DefNumberVector) { + if (!hasThermometer && message is DefNumberVector) { hasThermometer = true - sender.fireOnEventReceived(ThermometerAttached(this)) + sender.registerThermometer(this) } - temperature = message["TEMPERATURE"]!!.value + val value = message["TEMPERATURE"]!!.value - sender.fireOnEventReceived(FocuserTemperatureChanged(this)) + if (temperature != value) { + temperature = value + sender.fireOnEventReceived(FocuserTemperatureChanged(this)) + } } } } @@ -182,7 +181,7 @@ internal open class INDIFocuser( override fun close() { if (hasThermometer) { hasThermometer = false - sender.fireOnEventReceived(ThermometerDetached(this)) + sender.unregisterThermometer(this) } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/INDIMount.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/INDIMount.kt index 5f2f092b6..f2899d9f1 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/INDIMount.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/INDIMount.kt @@ -124,6 +124,10 @@ internal open class INDIMount( sender.fireOnEventReceived(MountCanSyncChanged(this)) sender.fireOnEventReceived(MountCanGoToChanged(this)) } + "TELESCOPE_HOME" -> { + canHome = true + sender.fireOnEventReceived(MountCanHomeChanged(this)) + } } } is NumberVector<*> -> { @@ -157,8 +161,6 @@ internal open class INDIMount( canPulseGuide = true sender.registerGuideOutput(this) - - LOG.info("guide output attached: {}", name) } if (canPulseGuide) { @@ -234,14 +236,22 @@ internal open class INDIMount( } override fun park() { - sendNewSwitch("TELESCOPE_PARK", "PARK" to true) + if (canPark) { + sendNewSwitch("TELESCOPE_PARK", "PARK" to true) + } } override fun unpark() { - sendNewSwitch("TELESCOPE_PARK", "UNPARK" to true) + if (canPark) { + sendNewSwitch("TELESCOPE_PARK", "UNPARK" to true) + } } - override fun home() = Unit + override fun home() { + if (canHome) { + sendNewSwitch("TELESCOPE_HOME", "GO" to true) + } + } override fun abortMotion() { if (canAbort) { @@ -319,13 +329,11 @@ internal open class INDIMount( if (canPulseGuide) { canPulseGuide = false sender.unregisterGuideOutput(this) - LOG.info("guide output detached: {}", name) } if (hasGPS) { hasGPS = false sender.unregisterGPS(this) - LOG.info("GPS detached: {}", name) } } diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/IoptronV3Mount.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/IoptronV3Mount.kt deleted file mode 100644 index 4ef2fc209..000000000 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/mounts/IoptronV3Mount.kt +++ /dev/null @@ -1,32 +0,0 @@ -package nebulosa.indi.client.device.mounts - -import nebulosa.indi.client.INDIClient -import nebulosa.indi.device.mount.MountCanHomeChanged -import nebulosa.indi.protocol.INDIProtocol -import nebulosa.indi.protocol.SwitchVector - -internal class IoptronV3Mount( - provider: INDIClient, - name: String, -) : INDIMount(provider, name) { - - override fun handleMessage(message: INDIProtocol) { - when (message) { - is SwitchVector<*> -> { - when (message.name) { - "HOME" -> { - canHome = true - sender.fireOnEventReceived(MountCanHomeChanged(this)) - } - } - } - else -> Unit - } - - super.handleMessage(message) - } - - override fun home() { - sendNewSwitch("HOME", "GoToHome" to true) - } -} diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/rotators/INDIRotator.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/rotators/INDIRotator.kt new file mode 100644 index 000000000..c56f75645 --- /dev/null +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/rotators/INDIRotator.kt @@ -0,0 +1,140 @@ +package nebulosa.indi.client.device.rotators + +import nebulosa.indi.client.INDIClient +import nebulosa.indi.client.device.INDIDevice +import nebulosa.indi.device.firstOnSwitch +import nebulosa.indi.device.rotator.* +import nebulosa.indi.protocol.* + +// https://github.com/indilib/indi/blob/master/libs/indibase/indirotatorinterface.cpp + +internal open class INDIRotator( + override val sender: INDIClient, + override val name: String, +) : INDIDevice(), Rotator { + + @Volatile final override var moving = false + @Volatile final override var canAbort = false + @Volatile final override var canHome = false + @Volatile final override var canSync = false + @Volatile final override var canReverse = false + @Volatile final override var reversed = false + @Volatile final override var hasBacklashCompensation = false + @Volatile final override var backslash = 0 + @Volatile final override var angle = 0.0 + @Volatile final override var minAngle = 0.0 + @Volatile final override var maxAngle = 0.0 + + override fun handleMessage(message: INDIProtocol) { + when (message) { + is DefNumberVector -> { + when (message.name) { + "ABS_ROTATOR_ANGLE" -> { + val angle = message["ANGLE"] ?: return + minAngle = angle.min + maxAngle = angle.max + + sender.fireOnEventReceived(RotatorMinMaxAngleChanged(this)) + + if (angle.value != 0.0) { + this.angle = angle.value + sender.fireOnEventReceived(RotatorAngleChanged(this)) + } + } + "SYNC_ROTATOR_ANGLE" -> { + canSync = true + sender.fireOnEventReceived(RotatorCanSyncChanged(this)) + } + } + } + is DefSwitchVector -> { + when (message.name) { + "ROTATOR_ABORT_MOTION" -> { + canAbort = true + sender.fireOnEventReceived(RotatorCanAbortChanged(this)) + } + "ROTATOR_HOME" -> { + canHome = true + sender.fireOnEventReceived(RotatorCanHomeChanged(this)) + } + "ROTATOR_REVERSE" -> { + canReverse = true + sender.fireOnEventReceived(RotatorCanReverseChanged(this)) + + handleReversed(message) + } + } + } + is SetNumberVector -> { + when (message.name) { + "ABS_ROTATOR_ANGLE" -> { + val angle = message["ANGLE"] ?: return + + if (moving != message.isBusy) { + this.moving = message.isBusy + sender.fireOnEventReceived(RotatorMovingChanged(this)) + } + + if (angle.value != this.angle) { + this.angle = angle.value + sender.fireOnEventReceived(RotatorAngleChanged(this)) + } + } + } + } + is SetSwitchVector -> { + when (message.name) { + "ROTATOR_REVERSE" -> { + handleReversed(message) + } + } + } + else -> Unit + } + + super.handleMessage(message) + } + + private fun handleReversed(message: SwitchVector<*>) { + val reversed = message.firstOnSwitch().name == "INDI_ENABLED" + + if (reversed != this.reversed) { + this.reversed = reversed + sender.fireOnEventReceived(RotatorReversedChanged(this)) + } + } + + override fun moveRotator(angle: Double) { + sendNewNumber("ABS_ROTATOR_ANGLE", "ANGLE" to angle) + } + + override fun syncRotator(angle: Double) { + if (canSync) { + sendNewNumber("SYNC_ROTATOR_ANGLE", "ANGLE" to angle) + } + } + + override fun homeRotator() { + if (canHome) { + sendNewSwitch("ROTATOR_HOME", "HOME" to true) + } + } + + override fun reverseRotator(enable: Boolean) { + if (canReverse) { + sendNewSwitch("ROTATOR_REVERSE", (if (enable) "INDI_ENABLED" else "INDI_DISABLED") to true) + } + } + + override fun abortRotator() { + if (canAbort) { + sendNewSwitch("ROTATOR_ABORT_MOTION", "ABORT" to true) + } + } + + override fun close() = Unit + + override fun toString() = "INDIRotator(name=$name, canAbort=$canAbort, canHome=$canHome, " + + "canSync=$canSync, canReverse=$canReverse, hasBacklashCompensation=$hasBacklashCompensation, " + + "backslash=$backslash, minAngle=$minAngle, maxAngle=$maxAngle)" +} diff --git a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/wheels/INDIFilterWheel.kt b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/wheels/INDIFilterWheel.kt index 412e8481f..530bea68f 100644 --- a/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/wheels/INDIFilterWheel.kt +++ b/nebulosa-indi-client/src/main/kotlin/nebulosa/indi/client/device/wheels/INDIFilterWheel.kt @@ -38,7 +38,7 @@ internal open class INDIFilterWheel( position = slot.value.toInt() if (prevPosition != position) { - sender.fireOnEventReceived(FilterWheelPositionChanged(this, prevPosition)) + sender.fireOnEventReceived(FilterWheelPositionChanged(this)) } val prevIsMoving = moving diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/AbstractINDIDeviceProvider.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/AbstractINDIDeviceProvider.kt new file mode 100644 index 000000000..235b7e947 --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/AbstractINDIDeviceProvider.kt @@ -0,0 +1,224 @@ +package nebulosa.indi.device + +import nebulosa.indi.device.camera.Camera +import nebulosa.indi.device.camera.CameraAttached +import nebulosa.indi.device.camera.CameraDetached +import nebulosa.indi.device.camera.GuideHead +import nebulosa.indi.device.filterwheel.FilterWheel +import nebulosa.indi.device.filterwheel.FilterWheelAttached +import nebulosa.indi.device.filterwheel.FilterWheelDetached +import nebulosa.indi.device.focuser.Focuser +import nebulosa.indi.device.focuser.FocuserAttached +import nebulosa.indi.device.focuser.FocuserDetached +import nebulosa.indi.device.gps.GPS +import nebulosa.indi.device.gps.GPSAttached +import nebulosa.indi.device.gps.GPSDetached +import nebulosa.indi.device.guide.GuideOutput +import nebulosa.indi.device.guide.GuideOutputAttached +import nebulosa.indi.device.guide.GuideOutputDetached +import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.mount.MountAttached +import nebulosa.indi.device.mount.MountDetached +import nebulosa.indi.device.rotator.Rotator +import nebulosa.indi.device.rotator.RotatorAttached +import nebulosa.indi.device.rotator.RotatorDetached +import nebulosa.indi.device.thermometer.Thermometer +import nebulosa.indi.device.thermometer.ThermometerAttached +import nebulosa.indi.device.thermometer.ThermometerDetached +import nebulosa.log.loggerFor + +abstract class AbstractINDIDeviceProvider : INDIDeviceProvider { + + private val handlers = LinkedHashSet() + + private val cameras = HashMap(2) + private val mounts = HashMap(2) + private val wheels = HashMap(2) + private val focusers = HashMap(2) + private val rotators = HashMap(2) + private val gps = HashMap(2) + private val guideOutputs = HashMap(2) + private val thermometers = HashMap(2) + + override fun registerDeviceEventHandler(handler: DeviceEventHandler) = handlers.add(handler) + + override fun unregisterDeviceEventHandler(handler: DeviceEventHandler) = handlers.remove(handler) + + fun fireOnEventReceived(event: DeviceEvent<*>) = handlers.forEach { it.onEventReceived(event) } + + fun fireOnConnectionClosed() = handlers.forEach { it.onConnectionClosed() } + + override fun cameras() = cameras.values + + override fun camera(id: String) = cameras[id] ?: cameras.values.find { it.name == id } + + override fun mounts() = mounts.values + + override fun mount(id: String) = mounts[id] ?: mounts.values.find { it.name == id } + + override fun focusers() = focusers.values + + override fun focuser(id: String) = focusers[id] ?: focusers.values.find { it.name == id } + + override fun wheels() = wheels.values + + override fun wheel(id: String) = wheels[id] ?: wheels.values.find { it.name == id } + + override fun rotators() = rotators.values + + override fun rotator(id: String) = rotators[id] ?: rotators.values.find { it.name == id } + + override fun gps() = gps.values + + override fun gps(id: String) = gps[id] ?: gps.values.find { it.name == id } + + override fun guideOutputs() = guideOutputs.values + + override fun guideOutput(id: String) = guideOutputs[id] ?: guideOutputs.values.find { it.name == id } + + override fun thermometers() = thermometers.values + + override fun thermometer(id: String) = thermometers[id] ?: thermometers.values.find { it.name == id } + + fun registerCamera(device: Camera): Boolean { + if (device.id in cameras) return false + cameras[device.id] = device + fireOnEventReceived(CameraAttached(device)) + LOG.info("camera attached: {} ({})", device.name, device.id) + return true + } + + fun unregisterCamera(device: Camera) { + fireOnEventReceived(CameraDetached(cameras.remove(device.id) ?: return)) + LOG.info("camera detached: {} ({})", device.name, device.id) + } + + fun registerMount(device: Mount): Boolean { + if (device.id in mounts) return false + mounts[device.id] = device + fireOnEventReceived(MountAttached(device)) + LOG.info("mount attached: {} ({})", device.name, device.id) + return true + } + + fun unregisterMount(device: Mount) { + fireOnEventReceived(MountDetached(mounts.remove(device.id) ?: return)) + LOG.info("mount detached: {} ({})", device.name, device.id) + } + + fun registerFocuser(device: Focuser): Boolean { + if (device.id in focusers) return false + focusers[device.id] = device + fireOnEventReceived(FocuserAttached(device)) + LOG.info("focuser attached: {} ({})", device.name, device.id) + return true + } + + fun unregisterFocuser(device: Focuser) { + fireOnEventReceived(FocuserDetached(focusers.remove(device.id) ?: return)) + LOG.info("focuser detached: {} ({})", device.name, device.id) + } + + fun registerRotator(device: Rotator): Boolean { + if (device.id in rotators) return false + rotators[device.id] = device + fireOnEventReceived(RotatorAttached(device)) + LOG.info("rotator attached: {} ({})", device.name, device.id) + return true + } + + fun unregisterRotator(device: Rotator) { + fireOnEventReceived(RotatorDetached(rotators.remove(device.id) ?: return)) + LOG.info("rotator detached: {} ({})", device.name, device.id) + } + + fun registerFilterWheel(device: FilterWheel): Boolean { + if (device.id in wheels) return false + wheels[device.id] = device + fireOnEventReceived(FilterWheelAttached(device)) + LOG.info("filter wheel attached: {} ({})", device.name, device.id) + return true + } + + fun unregisterFilterWheel(device: FilterWheel) { + fireOnEventReceived(FilterWheelDetached(wheels.remove(device.id) ?: return)) + LOG.info("filter wheel detached: {} ({})", device.name, device.id) + } + + fun registerGPS(device: GPS): Boolean { + if (device.id in gps) return false + gps[device.id] = device + fireOnEventReceived(GPSAttached(device)) + LOG.info("gps attached: {} ({})", device.name, device.id) + return true + } + + fun unregisterGPS(device: GPS) { + fireOnEventReceived(GPSDetached(gps.remove(device.id) ?: return)) + LOG.info("gps detached: {} ({})", device.name, device.id) + } + + fun registerGuideHead(device: GuideHead): Boolean { + if (device.id in cameras) return false + cameras[device.id] = device + fireOnEventReceived(CameraAttached(device)) + LOG.info("guide head attached: {} ({})", device.name, device.id) + return true + } + + fun unregisterGuiderHead(device: GuideHead) { + fireOnEventReceived(CameraDetached(cameras.remove(device.id) ?: return)) + LOG.info("guide head detached: {} ({})", device.name, device.id) + } + + fun registerGuideOutput(device: GuideOutput): Boolean { + if (device.id in guideOutputs) return false + guideOutputs[device.id] = device + fireOnEventReceived(GuideOutputAttached(device)) + LOG.info("guide output attached: {} ({})", device.name, device.id) + return true + } + + fun unregisterGuideOutput(device: GuideOutput) { + fireOnEventReceived(GuideOutputDetached(guideOutputs.remove(device.id) ?: return)) + LOG.info("guide output detached: {} ({})", device.name, device.id) + } + + fun registerThermometer(device: Thermometer): Boolean { + if (device.id in thermometers) return false + thermometers[device.id] = device + fireOnEventReceived(ThermometerAttached(device)) + LOG.info("thermometer attached: {} ({})", device.name, device.id) + return true + } + + fun unregisterThermometer(device: Thermometer) { + fireOnEventReceived(ThermometerDetached(thermometers.remove(device.id) ?: return)) + LOG.info("thermometer detached: {} ({})", device.name, device.id) + } + + override fun close() { + cameras().onEach(Device::close).onEach(::unregisterCamera) + mounts().onEach(Device::close).onEach(::unregisterMount) + wheels().onEach(Device::close).onEach(::unregisterFilterWheel) + focusers().onEach(Device::close).onEach(::unregisterFocuser) + rotators().onEach(Device::close).onEach(::unregisterRotator) + gps().onEach(Device::close).onEach(::unregisterGPS) + + cameras.clear() + mounts.clear() + wheels.clear() + focusers.clear() + rotators.clear() + gps.clear() + guideOutputs.clear() + thermometers.clear() + + handlers.clear() + } + + companion object { + + @JvmStatic private val LOG = loggerFor() + } +} diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/INDIDeviceProvider.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/INDIDeviceProvider.kt index 2f6083188..e8a23d0a0 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/INDIDeviceProvider.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/INDIDeviceProvider.kt @@ -6,40 +6,48 @@ import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.gps.GPS import nebulosa.indi.device.guide.GuideOutput import nebulosa.indi.device.mount.Mount +import nebulosa.indi.device.rotator.Rotator import nebulosa.indi.device.thermometer.Thermometer import java.io.Closeable interface INDIDeviceProvider : MessageSender, Closeable { - fun registerDeviceEventHandler(handler: DeviceEventHandler) + fun registerDeviceEventHandler(handler: DeviceEventHandler): Boolean - fun unregisterDeviceEventHandler(handler: DeviceEventHandler) + fun unregisterDeviceEventHandler(handler: DeviceEventHandler): Boolean - fun cameras(): List + fun device(id: String) = camera(id) ?: mount(id) ?: focuser(id) ?: wheel(id) + ?: rotator(id) ?: gps(id) ?: guideOutput(id) ?: thermometer(id) - fun camera(name: String): Camera? + fun cameras(): Collection - fun mounts(): List + fun camera(id: String): Camera? - fun mount(name: String): Mount? + fun mounts(): Collection - fun focusers(): List + fun mount(id: String): Mount? - fun focuser(name: String): Focuser? + fun focusers(): Collection - fun wheels(): List + fun focuser(id: String): Focuser? - fun wheel(name: String): FilterWheel? + fun wheels(): Collection - fun gps(): List + fun wheel(id: String): FilterWheel? - fun gps(name: String): GPS? + fun rotators(): Collection - fun guideOutputs(): List + fun rotator(id: String): Rotator? - fun guideOutput(name: String): GuideOutput? + fun gps(): Collection - fun thermometers(): List + fun gps(id: String): GPS? - fun thermometer(name: String): Thermometer? + fun guideOutputs(): Collection + + fun guideOutput(id: String): GuideOutput? + + fun thermometers(): Collection + + fun thermometer(id: String): Thermometer? } diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/Camera.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/Camera.kt index 3ec33dcb5..49e72e3b4 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/Camera.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/Camera.kt @@ -129,8 +129,8 @@ interface Camera : GuideOutput, Thermometer { "indi_altair_ccd", "indi_apogee_ccd", "indi_asi_ccd", - "indi_asi_single_ccd", "indi_atik_ccd", + "indi_bressercam_ccd", "indi_cam90_ccd", "indi_canon_ccd", "indi_dsi_ccd", @@ -138,22 +138,27 @@ interface Camera : GuideOutput, Thermometer { "indi_fishcamp_ccd", "indi_fli_ccd", "indi_fuji_ccd", + "indi_generic_ccd", "indi_gphoto_ccd", "indi_inovaplx_ccd", + "indi_kepler_ccd", + "indi_libcamera_ccd", "indi_mallincam_ccd", + "indi_meadecam_ccd", "indi_mi_ccd_eth", "indi_mi_ccd_usb", "indi_nightscape_ccd", "indi_nikon_ccd", "indi_nncam_ccd", + "indi_ogmacam_ccd", "indi_omegonprocam_ccd", "indi_orion_ssg3_ccd", - "indi_pentax_ccd", "indi_pentax", + "indi_pentax_ccd", "indi_playerone_ccd", + "indi_playerone_single_ccd", "indi_qhy_ccd", "indi_qsi_ccd", - "indi_rpicam", "indi_sbig_ccd", "indi_simulator_ccd", "indi_simulator_guide", @@ -162,9 +167,9 @@ interface Camera : GuideOutput, Thermometer { "indi_svbony_ccd", "indi_sx_ccd", "indi_toupcam_ccd", + "indi_tscam_ccd", "indi_v4l2_ccd", "indi_webcam_ccd", - "indi_kepler_ccd", ) } } diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/CameraExposureStateChanged.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/CameraExposureStateChanged.kt index d9ac8b8b0..1eacc1dc5 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/CameraExposureStateChanged.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/CameraExposureStateChanged.kt @@ -1,9 +1,5 @@ package nebulosa.indi.device.camera import nebulosa.indi.device.PropertyChangedEvent -import nebulosa.indi.protocol.PropertyState -data class CameraExposureStateChanged( - override val device: Camera, - val previousState: PropertyState, -) : CameraEvent, PropertyChangedEvent +data class CameraExposureStateChanged(override val device: Camera) : CameraEvent, PropertyChangedEvent diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheel.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheel.kt index ff8eee139..90c27c12f 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheel.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheel.kt @@ -19,19 +19,33 @@ interface FilterWheel : Device { companion object { @JvmStatic val DRIVERS = setOf( + "indi_altair_wheel", "indi_apogee_wheel", "indi_asi_wheel", "indi_atik_wheel", + "indi_bressercam_wheel", "indi_fli_wheel", + "indi_mallincam_wheel", "indi_manual_wheel", + "indi_mi_sfw_eth", + "indi_mi_sfw_usb", + "indi_nncam_ccd", + "indi_oasis_filter_wheel", + "indi_ogmacam_wheel", + "indi_omegonprocam_wheel", "indi_optec_wheel", + "indi_pegasusindigo_wheel", + "indi_playerone_wheel", "indi_qhycfw1_wheel", "indi_qhycfw2_wheel", "indi_qhycfw3_wheel", "indi_quantum_wheel", "indi_simulator_wheel", + "indi_starshootg_wheel", "indi_sx_wheel", + "indi_toupcam_wheel", "indi_trutech_wheel", + "indi_tscam_wheel", "indi_xagyl_wheel", ) } diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheelPositionChanged.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheelPositionChanged.kt index facdadbfa..7d865e88d 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheelPositionChanged.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/filterwheel/FilterWheelPositionChanged.kt @@ -2,7 +2,4 @@ package nebulosa.indi.device.filterwheel import nebulosa.indi.device.PropertyChangedEvent -data class FilterWheelPositionChanged( - override val device: FilterWheel, - val previous: Int, -) : FilterWheelEvent, PropertyChangedEvent +data class FilterWheelPositionChanged(override val device: FilterWheel) : FilterWheelEvent, PropertyChangedEvent diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/focuser/Focuser.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/focuser/Focuser.kt index a18b1e2c9..920702192 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/focuser/Focuser.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/focuser/Focuser.kt @@ -39,11 +39,20 @@ interface Focuser : Device, Thermometer { companion object { + // grep -rl --include \*.h "public INDI::Focuser" . @JvmStatic val DRIVERS = setOf( "indi_aaf2_focus", "indi_activefocuser_focus", + "indi_alluna_tcs2", "indi_armadillo_focus", "indi_asi_focuser", + "indi_astrolink4", + "indi_astrolink4mini2", + "indi_astromechfoc", + "indi_avalonud_focuser", + "indi_beefocus", + "indi_celestron_aux", + "indi_celestron_gps", "indi_celestron_sct_focus", "indi_deepskydad_af1_focus", "indi_deepskydad_af2_focus", @@ -56,22 +65,33 @@ interface Focuser : Device, Thermometer { "indi_fcusb_focus", "indi_fli_focus", "indi_gemini_focus", + "indi_gphoto_ccd", "indi_hitecastrodc_focus", - "indi_integra_focus", + "indi_ieaf_focus", "indi_lacerta_mfoc_fmc_focus", "indi_lacerta_mfoc_focus", "indi_lakeside_focus", + "indi_lx200generic", + "indi_lx200stargo", "indi_lynx_focus", "indi_microtouch_focus", "indi_moonlite_focus", "indi_moonlitedro_focus", "indi_myfocuserpro2_focus", + "indi_nfocus", "indi_nightcrawler_focus", "indi_nstep_focus", + "indi_oasis_focuser", "indi_onfocus_focus", "indi_pegasus_focuscube", + "indi_pegasus_focuscube3", + "indi_pegasus_ppba", + "indi_pegasus_prodigyMF", + "indi_pegasus_scopsoag", + "indi_pegasus_upb", "indi_perfectstar_focus", "indi_platypus_focus", + "indi_qhy_focuser", "indi_rainbowrsf_focus", "indi_rbfocus_focus", "indi_robo_focus", @@ -86,6 +106,7 @@ interface Focuser : Device, Thermometer { "indi_tcfs_focus", "indi_teenastro_focus", "indi_usbfocusv3_focus", + "indilx200", ) } } diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/gps/GPS.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/gps/GPS.kt index 1f7bd73a5..4d2deb814 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/gps/GPS.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/gps/GPS.kt @@ -38,6 +38,8 @@ interface GPS : Device { } @JvmStatic val DRIVERS = setOf( + "indi_gpsd", + "indi_gpsnmea", "indi_simulator_gps", ) } diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/mount/Mount.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/mount/Mount.kt index 1a4bc7d5e..847336c4e 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/mount/Mount.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/mount/Mount.kt @@ -78,7 +78,9 @@ interface Mount : GuideOutput, GPS, Parkable { companion object { @JvmStatic val DRIVERS = setOf( + "indi_ahpgt_telescope", "indi_astrotrac_telescope", + "indi_avalonud_telescope", "indi_azgti_telescope", "indi_bresserexos2", "indi_celestron_aux", @@ -94,12 +96,12 @@ interface Mount : GuideOutput, GPS, Parkable { "indi_lx200_10micron", "indi_lx200_16", "indi_lx200_OnStep", + "indi_lx200_OpenAstroTech", "indi_lx200_TeenAstro", + "indi_lx200_pegasus_nyx101", "indi_lx200am5", "indi_lx200aok", - "indi_lx200ap_gtocp2", "indi_lx200ap_v2", - "indi_lx200ap", "indi_lx200autostar", "indi_lx200basic", "indi_lx200classic", @@ -107,12 +109,12 @@ interface Mount : GuideOutput, GPS, Parkable { "indi_lx200gemini", "indi_lx200gotonova", "indi_lx200gps", - "indi_lx200_OpenAstroTech", "indi_lx200pulsar2", "indi_lx200ss2000pc", "indi_lx200stargo", "indi_lx200zeq25", "indi_paramount_telescope", + "indi_planewave_telescope", "indi_pmc8_telescope", "indi_rainbow_telescope", "indi_script_telescope", @@ -120,11 +122,13 @@ interface Mount : GuideOutput, GPS, Parkable { "indi_skycommander_telescope", "indi_skywatcherAltAzMount", "indi_staradventurer2i_telescope", + "indi_staradventurergti_telescope", "indi_starbook_telescope", "indi_starbook_ten", "indi_synscan_telescope", "indi_synscanlegacy_telescope", "indi_temma_telescope", + "shelyak_usis", ) } } diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/Rotator.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/Rotator.kt index 885b46421..d22861982 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/Rotator.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/Rotator.kt @@ -2,4 +2,59 @@ package nebulosa.indi.device.rotator import nebulosa.indi.device.Device -interface Rotator : Device +interface Rotator : Device { + + val moving: Boolean + + val canAbort: Boolean + + val canHome: Boolean + + val canSync: Boolean + + val canReverse: Boolean + + val reversed: Boolean + + val hasBacklashCompensation: Boolean + + val backslash: Int + + val angle: Double + + val minAngle: Double + + val maxAngle: Double + + fun moveRotator(angle: Double) + + fun syncRotator(angle: Double) + + fun homeRotator() + + fun reverseRotator(enable: Boolean) + + fun abortRotator() + + companion object { + + // grep -rl --include \*.h "public INDI::Rotator" . + @JvmStatic val DRIVERS = setOf( + "indi_deepskydad_fr1", + "indi_esattoarco_focus", + "indi_falcon_rotator", + "indi_gemini_focus", + "indi_integra_focus", + "indi_lx200generic", + "indi_nframe_rotator", + "indi_nightcrawler_focus", + "indi_nstep_rotator", + "indi_pyxis_rotator", + "indi_seletek_rotator", + "indi_simulator_rotator", + "indi_wanderer_lite_rotator", + "indi_wanderer_rotator_lite_v2", + "indi_wanderer_rotator_mini", + ) + } +} diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorAngleChanged.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorAngleChanged.kt new file mode 100644 index 000000000..4a2be1aa1 --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorAngleChanged.kt @@ -0,0 +1,5 @@ +package nebulosa.indi.device.rotator + +import nebulosa.indi.device.PropertyChangedEvent + +data class RotatorAngleChanged(override val device: Rotator) : RotatorEvent, PropertyChangedEvent diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorAttached.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorAttached.kt new file mode 100644 index 000000000..253383036 --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorAttached.kt @@ -0,0 +1,5 @@ +package nebulosa.indi.device.rotator + +import nebulosa.indi.device.DeviceAttached + +data class RotatorAttached(override val device: Rotator) : RotatorEvent, DeviceAttached diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorCanAbortChanged.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorCanAbortChanged.kt new file mode 100644 index 000000000..97ce3a0b2 --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorCanAbortChanged.kt @@ -0,0 +1,5 @@ +package nebulosa.indi.device.rotator + +import nebulosa.indi.device.PropertyChangedEvent + +data class RotatorCanAbortChanged(override val device: Rotator) : RotatorEvent, PropertyChangedEvent diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorCanHomeChanged.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorCanHomeChanged.kt new file mode 100644 index 000000000..2e6b47de6 --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorCanHomeChanged.kt @@ -0,0 +1,5 @@ +package nebulosa.indi.device.rotator + +import nebulosa.indi.device.PropertyChangedEvent + +data class RotatorCanHomeChanged(override val device: Rotator) : RotatorEvent, PropertyChangedEvent diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorCanReverseChanged.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorCanReverseChanged.kt new file mode 100644 index 000000000..d91be4e26 --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorCanReverseChanged.kt @@ -0,0 +1,5 @@ +package nebulosa.indi.device.rotator + +import nebulosa.indi.device.PropertyChangedEvent + +data class RotatorCanReverseChanged(override val device: Rotator) : RotatorEvent, PropertyChangedEvent diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorCanSyncChanged.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorCanSyncChanged.kt new file mode 100644 index 000000000..e055be879 --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorCanSyncChanged.kt @@ -0,0 +1,5 @@ +package nebulosa.indi.device.rotator + +import nebulosa.indi.device.PropertyChangedEvent + +data class RotatorCanSyncChanged(override val device: Rotator) : RotatorEvent, PropertyChangedEvent diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorDetached.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorDetached.kt new file mode 100644 index 000000000..aed1ebce5 --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorDetached.kt @@ -0,0 +1,5 @@ +package nebulosa.indi.device.rotator + +import nebulosa.indi.device.DeviceDetached + +data class RotatorDetached(override val device: Rotator) : RotatorEvent, DeviceDetached diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorEvent.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorEvent.kt new file mode 100644 index 000000000..15e721351 --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorEvent.kt @@ -0,0 +1,5 @@ +package nebulosa.indi.device.rotator + +import nebulosa.indi.device.DeviceEvent + +interface RotatorEvent : DeviceEvent diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorMinMaxAngleChanged.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorMinMaxAngleChanged.kt new file mode 100644 index 000000000..480b22a34 --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorMinMaxAngleChanged.kt @@ -0,0 +1,5 @@ +package nebulosa.indi.device.rotator + +import nebulosa.indi.device.PropertyChangedEvent + +data class RotatorMinMaxAngleChanged(override val device: Rotator) : RotatorEvent, PropertyChangedEvent diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorMovingChanged.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorMovingChanged.kt new file mode 100644 index 000000000..b6b41f0cb --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorMovingChanged.kt @@ -0,0 +1,5 @@ +package nebulosa.indi.device.rotator + +import nebulosa.indi.device.PropertyChangedEvent + +data class RotatorMovingChanged(override val device: Rotator) : RotatorEvent, PropertyChangedEvent diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorReversedChanged.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorReversedChanged.kt new file mode 100644 index 000000000..3566dbd37 --- /dev/null +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/rotator/RotatorReversedChanged.kt @@ -0,0 +1,5 @@ +package nebulosa.indi.device.rotator + +import nebulosa.indi.device.PropertyChangedEvent + +data class RotatorReversedChanged(override val device: Rotator) : RotatorEvent, PropertyChangedEvent diff --git a/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/INDIProtocolReader.kt b/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/INDIProtocolReader.kt index 2ccd92661..40167f9d1 100644 --- a/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/INDIProtocolReader.kt +++ b/nebulosa-indi-protocol/src/main/kotlin/nebulosa/indi/protocol/parser/INDIProtocolReader.kt @@ -6,13 +6,14 @@ import java.io.Closeable class INDIProtocolReader( private val parser: INDIProtocolParser, priority: Int = NORM_PRIORITY, -) : Thread(), Closeable { +) : Thread("INDI Protocol Reader"), Closeable { - private val listeners = HashSet(1) + private val listeners = LinkedHashSet(1) @Volatile private var running = false init { + isDaemon = true setPriority(priority) } @@ -59,6 +60,7 @@ class INDIProtocolReader( if (!running) return running = false + listeners.clear() interrupt() } diff --git a/nebulosa-io/src/main/kotlin/nebulosa/io/Io.kt b/nebulosa-io/src/main/kotlin/nebulosa/io/Io.kt index bca1fda39..214ff1eec 100644 --- a/nebulosa-io/src/main/kotlin/nebulosa/io/Io.kt +++ b/nebulosa-io/src/main/kotlin/nebulosa/io/Io.kt @@ -158,10 +158,7 @@ inline fun InputStream.transferAndCloseOutput(output: OutputStream) = output.use inline fun InputStream.transferAndClose(output: OutputStream) = use { output.use(::transferTo) } -inline fun Buffer.read(source: Source, byteCount: Long): Buffer { - source.read(this, byteCount) - return this -} +inline fun Buffer.read(source: Source, byteCount: Long) = apply { source.read(this, byteCount) } inline fun Buffer.read(source: Source, byteCount: Long, block: (Buffer) -> T): T { source.read(this, byteCount) diff --git a/nebulosa-lx200-protocol/build.gradle.kts b/nebulosa-lx200-protocol/build.gradle.kts index 618b344a4..28944633f 100644 --- a/nebulosa-lx200-protocol/build.gradle.kts +++ b/nebulosa-lx200-protocol/build.gradle.kts @@ -6,6 +6,7 @@ plugins { dependencies { api(project(":nebulosa-erfa")) api(project(":nebulosa-netty")) + compileOnly(project(":nebulosa-indi-device")) implementation(project(":nebulosa-log")) testImplementation(project(":nebulosa-test")) } diff --git a/nebulosa-lx200-protocol/src/main/kotlin/nebulosa/lx200/protocol/LX200MountHandler.kt b/nebulosa-lx200-protocol/src/main/kotlin/nebulosa/lx200/protocol/LX200MountHandler.kt index d8269a44f..670c899fe 100644 --- a/nebulosa-lx200-protocol/src/main/kotlin/nebulosa/lx200/protocol/LX200MountHandler.kt +++ b/nebulosa-lx200-protocol/src/main/kotlin/nebulosa/lx200/protocol/LX200MountHandler.kt @@ -5,9 +5,9 @@ import java.time.OffsetDateTime interface LX200MountHandler { - val rightAscensionJ2000: Angle + val rightAscension: Angle - val declinationJ2000: Angle + val declination: Angle val latitude: Angle @@ -25,13 +25,13 @@ interface LX200MountHandler { fun abort() - fun moveNorth(enable: Boolean) + fun moveNorth(enabled: Boolean) - fun moveSouth(enable: Boolean) + fun moveSouth(enabled: Boolean) - fun moveWest(enable: Boolean) + fun moveWest(enabled: Boolean) - fun moveEast(enable: Boolean) + fun moveEast(enabled: Boolean) fun time(time: OffsetDateTime) diff --git a/nebulosa-lx200-protocol/src/main/kotlin/nebulosa/lx200/protocol/LX200MountHandlerAdapter.kt b/nebulosa-lx200-protocol/src/main/kotlin/nebulosa/lx200/protocol/LX200MountHandlerAdapter.kt new file mode 100644 index 000000000..f6f6c61a0 --- /dev/null +++ b/nebulosa-lx200-protocol/src/main/kotlin/nebulosa/lx200/protocol/LX200MountHandlerAdapter.kt @@ -0,0 +1,65 @@ +package nebulosa.lx200.protocol + +import nebulosa.indi.device.mount.Mount +import nebulosa.math.Angle +import java.time.OffsetDateTime + +data class LX200MountHandlerAdapter(private val mount: Mount) : LX200MountHandler { + + override val rightAscension + get() = mount.rightAscension + + override val declination + get() = mount.declination + + override val latitude + get() = mount.latitude + + override val longitude + get() = mount.longitude + + override val slewing + get() = mount.slewing + + override val tracking + get() = mount.tracking + + override val parked + get() = mount.parked + + override fun goTo(rightAscension: Angle, declination: Angle) { + mount.goToJ2000(rightAscension, declination) + } + + override fun syncTo(rightAscension: Angle, declination: Angle) { + mount.syncJ2000(rightAscension, declination) + } + + override fun abort() { + mount.abortMotion() + } + + override fun moveNorth(enabled: Boolean) { + mount.moveNorth(enabled) + } + + override fun moveSouth(enabled: Boolean) { + mount.moveSouth(enabled) + } + + override fun moveWest(enabled: Boolean) { + mount.moveWest(enabled) + } + + override fun moveEast(enabled: Boolean) { + mount.moveEast(enabled) + } + + override fun time(time: OffsetDateTime) { + mount.dateTime(time) + } + + override fun coordinates(longitude: Angle, latitude: Angle) { + mount.coordinates(longitude, latitude, mount.elevation) + } +} diff --git a/nebulosa-lx200-protocol/src/main/kotlin/nebulosa/lx200/protocol/LX200ProtocolServer.kt b/nebulosa-lx200-protocol/src/main/kotlin/nebulosa/lx200/protocol/LX200ProtocolServer.kt index 949994ef1..744cad3a9 100644 --- a/nebulosa-lx200-protocol/src/main/kotlin/nebulosa/lx200/protocol/LX200ProtocolServer.kt +++ b/nebulosa-lx200-protocol/src/main/kotlin/nebulosa/lx200/protocol/LX200ProtocolServer.kt @@ -15,32 +15,32 @@ import java.util.concurrent.atomic.AtomicReference * * @see Meade Telescope Serial Command Protocol */ -class LX200ProtocolServer( +data class LX200ProtocolServer( override val host: String = "0.0.0.0", override val port: Int = 10001, -) : NettyServer() { +) : NettyServer(), LX200MountHandler { private val mountHandler = AtomicReference() - val rightAscension - get() = mountHandler.get()?.rightAscensionJ2000 ?: 0.0 + override val rightAscension + get() = mountHandler.get()?.rightAscension ?: 0.0 - val declination - get() = mountHandler.get()?.declinationJ2000 ?: 0.0 + override val declination + get() = mountHandler.get()?.declination ?: 0.0 - val latitude + override val latitude get() = mountHandler.get()?.latitude ?: 0.0 - val longitude + override val longitude get() = mountHandler.get()?.longitude ?: 0.0 - val slewing + override val slewing get() = mountHandler.get()?.slewing ?: false - val tracking + override val tracking get() = mountHandler.get()?.tracking ?: false - val parked + override val parked get() = mountHandler.get()?.parked ?: false override val channelInitialzer = object : ChannelInitializer() { @@ -54,6 +54,7 @@ class LX200ProtocolServer( } fun attachMountHandler(handler: LX200MountHandler) { + require(handler !== this) { "cannot attach this server" } mountHandler.set(handler) } @@ -62,55 +63,55 @@ class LX200ProtocolServer( } @Synchronized - internal fun goTo(rightAscension: Angle, declination: Angle) { + override fun goTo(rightAscension: Angle, declination: Angle) { LOG.info("going to. ra={}, dec={}", rightAscension.toHours, declination.toDegrees) mountHandler.get()?.goTo(rightAscension, declination) } @Synchronized - internal fun syncTo(rightAscension: Angle, declination: Angle) { + override fun syncTo(rightAscension: Angle, declination: Angle) { LOG.info("syncing to. ra={}, dec={}", rightAscension.toHours, declination.toDegrees) mountHandler.get()?.syncTo(rightAscension, declination) } @Synchronized - internal fun moveNorth(enable: Boolean) { - LOG.info("moving to north. enable={}", enable) - mountHandler.get()?.moveNorth(enable) + override fun moveNorth(enabled: Boolean) { + LOG.info("moving to north. enabled={}", enabled) + mountHandler.get()?.moveNorth(enabled) } @Synchronized - internal fun moveSouth(enable: Boolean) { - LOG.info("moving to south. enable={}", enable) - mountHandler.get()?.moveSouth(enable) + override fun moveSouth(enabled: Boolean) { + LOG.info("moving to south. enabled={}", enabled) + mountHandler.get()?.moveSouth(enabled) } @Synchronized - internal fun moveWest(enable: Boolean) { - LOG.info("moving to west. enable={}", enable) - mountHandler.get()?.moveWest(enable) + override fun moveWest(enabled: Boolean) { + LOG.info("moving to west. enabled={}", enabled) + mountHandler.get()?.moveWest(enabled) } @Synchronized - internal fun moveEast(enable: Boolean) { - LOG.info("moving to east. enable={}", enable) - mountHandler.get()?.moveEast(enable) + override fun moveEast(enabled: Boolean) { + LOG.info("moving to east. enabled={}", enabled) + mountHandler.get()?.moveEast(enabled) } @Synchronized - internal fun time(time: OffsetDateTime) { + override fun time(time: OffsetDateTime) { LOG.info("sending time. time={}", time) mountHandler.get()?.time(time) } @Synchronized - internal fun coordinates(longitude: Angle, latitude: Angle) { + override fun coordinates(longitude: Angle, latitude: Angle) { LOG.info("sending coordinates. longitude={}, latitude={}", longitude.toDegrees, latitude.toDegrees) mountHandler.get()?.coordinates(longitude, latitude) } @Synchronized - internal fun abort() { + override fun abort() { LOG.info("aborting") mountHandler.get()?.abort() } diff --git a/nebulosa-lx200-protocol/src/test/kotlin/LX200ProtocolServerTest.kt b/nebulosa-lx200-protocol/src/test/kotlin/LX200ProtocolServerTest.kt index 429f5ea73..940ba0a3e 100644 --- a/nebulosa-lx200-protocol/src/test/kotlin/LX200ProtocolServerTest.kt +++ b/nebulosa-lx200-protocol/src/test/kotlin/LX200ProtocolServerTest.kt @@ -14,19 +14,18 @@ class LX200ProtocolServerTest { val server = LX200ProtocolServer(port = 10001) server.attachMountHandler(this@Companion) - server.run() Thread.currentThread().join() } - override var rightAscensionJ2000 = "05 15 07".hours + override var rightAscension = "05 15 07".hours - override var declinationJ2000 = "25 26 03".deg + override var declination = "25 26 03".deg - override val latitude = 0.0 + override var latitude = 0.0 - override val longitude = 0.0 + override var longitude = 0.0 override var slewing = false @@ -35,27 +34,30 @@ class LX200ProtocolServerTest { override var parked = false override fun goTo(rightAscension: Angle, declination: Angle) { - this.rightAscensionJ2000 = rightAscension - this.declinationJ2000 = declination + this.rightAscension = rightAscension + this.declination = declination } override fun syncTo(rightAscension: Angle, declination: Angle) { - this.rightAscensionJ2000 = rightAscension - this.declinationJ2000 = declination + this.rightAscension = rightAscension + this.declination = declination } - override fun moveNorth(enable: Boolean) {} + override fun moveNorth(enabled: Boolean) = Unit - override fun moveSouth(enable: Boolean) {} + override fun moveSouth(enabled: Boolean) = Unit - override fun moveWest(enable: Boolean) {} + override fun moveWest(enabled: Boolean) = Unit - override fun moveEast(enable: Boolean) {} + override fun moveEast(enabled: Boolean) = Unit - override fun time(time: OffsetDateTime) {} + override fun time(time: OffsetDateTime) = Unit - override fun coordinates(longitude: Angle, latitude: Angle) {} + override fun coordinates(longitude: Angle, latitude: Angle) { + this.longitude = longitude + this.latitude = latitude + } - override fun abort() {} + override fun abort() = Unit } } diff --git a/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/ELPMPP02.kt b/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/ELPMPP02.kt index 81bdb9db4..9061cdeca 100644 --- a/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/ELPMPP02.kt +++ b/nebulosa-nova/src/main/kotlin/nebulosa/nova/astrometry/ELPMPP02.kt @@ -5,6 +5,7 @@ import nebulosa.erfa.PositionAndVelocity import nebulosa.io.bufferedResource import nebulosa.math.Matrix3D import nebulosa.math.Vector3D +import nebulosa.math.normalized import nebulosa.math.pmod import nebulosa.time.InstantOfTime import kotlin.math.atan2 @@ -395,7 +396,7 @@ object ELPMPP02 : Body { val ifi = IntArray(13) { line.substring((45 + it * 3)..(47 + it * 3)).trim().toInt() } cper[idx] = sqrt(c * c + s * s) - fper[idx][0] = atan2(c, s) pmod TAU + fper[idx][0] = atan2(c, s).normalized for (k in 0..4) { fper[idx][k] += ifi[0] * del[0][k] diff --git a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/Barycentric.kt b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/Barycentric.kt index d325f7421..a7f6a66ad 100644 --- a/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/Barycentric.kt +++ b/nebulosa-nova/src/main/kotlin/nebulosa/nova/position/Barycentric.kt @@ -3,7 +3,7 @@ package nebulosa.nova.position import nebulosa.constants.TAU import nebulosa.math.Angle import nebulosa.math.Vector3D -import nebulosa.math.pmod +import nebulosa.math.normalized import nebulosa.nova.astrometry.Body import nebulosa.nova.astrometry.Observable import nebulosa.nova.frame.Ecliptic @@ -62,7 +62,7 @@ class Barycentric internal constructor( fun elongation(target: Body, center: Body): Double { val mLon = observe(target).latLon(Ecliptic).theta val sLon = observe(center).latLon(Ecliptic).phi - val angle = (mLon - sLon) pmod TAU + val angle = (mLon - sLon).normalized return angle / TAU } } diff --git a/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolution.kt b/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolution.kt index e0ee3c598..a09b66de3 100644 --- a/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolution.kt +++ b/nebulosa-plate-solving/src/main/kotlin/nebulosa/plate/solving/PlateSolution.kt @@ -13,15 +13,17 @@ import kotlin.math.cos import kotlin.math.hypot data class PlateSolution( - val solved: Boolean = false, - val orientation: Angle = 0.0, // CROTA2 - val scale: Angle = 0.0, // CDELT2 - val rightAscension: Angle = 0.0, // CRVAL1 - val declination: Angle = 0.0, // CRVAL2 - val width: Angle = 0.0, - val height: Angle = 0.0, - val parity: Parity = Parity.NORMAL, - val radius: Angle = hypot(width, height).rad / 2.0, + @JvmField val solved: Boolean = false, + @JvmField val orientation: Angle = 0.0, // CROTA2 + @JvmField val scale: Angle = 0.0, // CDELT2 + @JvmField val rightAscension: Angle = 0.0, // CRVAL1 + @JvmField val declination: Angle = 0.0, // CRVAL2 + @JvmField val width: Angle = 0.0, + @JvmField val height: Angle = 0.0, + @JvmField val parity: Parity = Parity.NORMAL, + @JvmField val radius: Angle = hypot(width, height).rad / 2.0, + @JvmField val widthInPixels: Double = width / scale, + @JvmField val heightInPixels: Double = height / scale, private val header: Collection = emptyList(), ) : FitsHeader.ReadOnly(header) { @@ -51,7 +53,10 @@ data class PlateSolution( crval1.formatHMS(), crval2.formatSignedDMS(), ) - return PlateSolution(true, crota2, cdelt2, crval1, crval2, abs(cdelt1 * width), abs(cdelt2 * height), header = header) + return PlateSolution( + true, crota2, cdelt2, crval1, crval2, abs(cdelt1 * width), abs(cdelt2 * height), + widthInPixels = width.toDouble(), heightInPixels = height.toDouble(), header = header + ) } } } diff --git a/nebulosa-retrofit/src/main/kotlin/nebulosa/retrofit/EnumToStringConverter.kt b/nebulosa-retrofit/src/main/kotlin/nebulosa/retrofit/EnumToStringConverter.kt new file mode 100644 index 000000000..59f66a098 --- /dev/null +++ b/nebulosa-retrofit/src/main/kotlin/nebulosa/retrofit/EnumToStringConverter.kt @@ -0,0 +1,11 @@ +package nebulosa.retrofit + +import com.fasterxml.jackson.databind.ObjectMapper +import retrofit2.Converter + +data class EnumToStringConverter(private val mapper: ObjectMapper) : Converter, String> { + + override fun convert(value: Enum<*>): String? { + return mapper.writeValueAsString(value) + } +} diff --git a/nebulosa-retrofit/src/main/kotlin/nebulosa/retrofit/EnumToStringConverterFactory.kt b/nebulosa-retrofit/src/main/kotlin/nebulosa/retrofit/EnumToStringConverterFactory.kt new file mode 100644 index 000000000..babb4d76e --- /dev/null +++ b/nebulosa-retrofit/src/main/kotlin/nebulosa/retrofit/EnumToStringConverterFactory.kt @@ -0,0 +1,15 @@ +package nebulosa.retrofit + +import com.fasterxml.jackson.databind.ObjectMapper +import retrofit2.Converter +import retrofit2.Retrofit +import java.lang.reflect.Type + +data class EnumToStringConverterFactory(private val mapper: ObjectMapper) : Converter.Factory() { + + private val converter = EnumToStringConverter(mapper) + + override fun stringConverter(type: Type, annotations: Array, retrofit: Retrofit): Converter<*, String>? { + return if (type is Class<*> && type.isEnum) converter else null + } +} diff --git a/nebulosa-retrofit/src/main/kotlin/nebulosa/retrofit/RetrofitService.kt b/nebulosa-retrofit/src/main/kotlin/nebulosa/retrofit/RetrofitService.kt index df19f423b..34bd3b82f 100644 --- a/nebulosa-retrofit/src/main/kotlin/nebulosa/retrofit/RetrofitService.kt +++ b/nebulosa-retrofit/src/main/kotlin/nebulosa/retrofit/RetrofitService.kt @@ -2,7 +2,10 @@ package nebulosa.retrofit import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.MapperFeature import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import okhttp3.ConnectionPool import okhttp3.OkHttpClient import retrofit2.CallAdapter @@ -14,31 +17,32 @@ import java.util.concurrent.TimeUnit abstract class RetrofitService( url: String, httpClient: OkHttpClient? = null, - objectMapper: ObjectMapper? = null, + mapper: ObjectMapper? = null, ) { - protected val mapper by lazy { objectMapper ?: DEFAULT_MAPPER.copy()!! } + protected val jsonMapper: ObjectMapper by lazy { mapper ?: DEFAULT_MAPPER.copy() } protected open val converterFactory = emptyList() protected open val callAdaptorFactory: CallAdapter.Factory? = null - protected open fun handleOkHttpClientBuilder(builder: OkHttpClient.Builder) = Unit + protected open fun withOkHttpClientBuilder(builder: OkHttpClient.Builder) = Unit - protected open fun handleObjectMapper(mapper: ObjectMapper) = Unit + protected open fun withObjectMapper(mapper: ObjectMapper) = Unit protected open val retrofit by lazy { val builder = Retrofit.Builder() builder.baseUrl(url.trim().let { if (it.endsWith("/")) it else "$it/" }) builder.addConverterFactory(RawAsStringConverterFactory) builder.addConverterFactory(RawAsByteArrayConverterFactory) + builder.addConverterFactory(EnumToStringConverterFactory(jsonMapper)) converterFactory.forEach { builder.addConverterFactory(it) } - handleObjectMapper(mapper) - builder.addConverterFactory(JacksonConverterFactory.create(mapper)) + withObjectMapper(jsonMapper) + builder.addConverterFactory(JacksonConverterFactory.create(jsonMapper)) callAdaptorFactory?.also(builder::addCallAdapterFactory) with((httpClient ?: HTTP_CLIENT).newBuilder()) { - handleOkHttpClientBuilder(this) + withOkHttpClientBuilder(this) builder.client(build()) } @@ -47,7 +51,7 @@ abstract class RetrofitService( companion object { - @JvmStatic private val CONNECTION_POOL = ConnectionPool(32, 30L, TimeUnit.MINUTES) + @JvmStatic private val CONNECTION_POOL = ConnectionPool(32, 5L, TimeUnit.MINUTES) @JvmStatic private val HTTP_CLIENT = OkHttpClient.Builder() .connectionPool(CONNECTION_POOL) @@ -57,8 +61,11 @@ abstract class RetrofitService( .callTimeout(60L, TimeUnit.SECONDS) .build() - @JvmStatic private val DEFAULT_MAPPER = ObjectMapper() - .setSerializationInclusion(JsonInclude.Include.NON_NULL) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)!! + @JvmStatic private val DEFAULT_MAPPER = JsonMapper.builder() + .addModule(JavaTimeModule()) + .serializationInclusion(JsonInclude.Include.NON_NULL) + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build()!! } } diff --git a/nebulosa-sbd/src/main/kotlin/nebulosa/sbd/SmallBodyDatabaseService.kt b/nebulosa-sbd/src/main/kotlin/nebulosa/sbd/SmallBodyDatabaseService.kt index d0aeb0e7a..9b6239583 100644 --- a/nebulosa-sbd/src/main/kotlin/nebulosa/sbd/SmallBodyDatabaseService.kt +++ b/nebulosa-sbd/src/main/kotlin/nebulosa/sbd/SmallBodyDatabaseService.kt @@ -15,7 +15,7 @@ class SmallBodyDatabaseService( private val service by lazy { retrofit.create() } - override fun handleOkHttpClientBuilder(builder: OkHttpClient.Builder) { + override fun withOkHttpClientBuilder(builder: OkHttpClient.Builder) { builder.addInterceptor { val response = it.proceed(it.request()) diff --git a/nebulosa-sbd/src/test/kotlin/SmallBodyDatabaseLookupServiceTest.kt b/nebulosa-sbd/src/test/kotlin/SmallBodyDatabaseLookupServiceTest.kt index a6f583e4f..a01795101 100644 --- a/nebulosa-sbd/src/test/kotlin/SmallBodyDatabaseLookupServiceTest.kt +++ b/nebulosa-sbd/src/test/kotlin/SmallBodyDatabaseLookupServiceTest.kt @@ -1,3 +1,4 @@ +import io.kotest.core.annotation.EnabledIf import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.ints.shouldBeExactly @@ -6,7 +7,9 @@ import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import nebulosa.sbd.SmallBodyDatabaseService +import nebulosa.test.NonGitHubOnlyCondition +@EnabledIf(NonGitHubOnlyCondition::class) class SmallBodyDatabaseLookupServiceTest : StringSpec() { init { diff --git a/nebulosa-simbad/src/main/kotlin/nebulosa/simbad/SimbadSearch.kt b/nebulosa-simbad/src/main/kotlin/nebulosa/simbad/SimbadSearch.kt index e18e288fb..cd328cdf7 100644 --- a/nebulosa-simbad/src/main/kotlin/nebulosa/simbad/SimbadSearch.kt +++ b/nebulosa-simbad/src/main/kotlin/nebulosa/simbad/SimbadSearch.kt @@ -8,20 +8,20 @@ import nebulosa.skycatalog.SkyObjectType @Suppress("ArrayInDataClass") data class SimbadSearch( - internal val id: Long = NO_ID, - internal val text: String? = null, - internal val rightAscension: Angle = 0.0, - internal val declination: Angle = 0.0, - internal val radius: Angle = 0.0, - internal val types: List? = null, - internal val magnitudeMin: Double = SkyObject.MAGNITUDE_MIN, - internal val magnitudeMax: Double = SkyObject.MAGNITUDE_MAX, - internal val constellation: Constellation? = null, - internal val ids: LongArray = LongArray(0), - internal val lastID: Long = NO_ID, - internal val limit: Int = SimbadService.DEFAULT_LIMIT, - internal val sortType: SortType = SortType.OID, - internal val sortDirection: SortDirection = SortDirection.ASCENDING, + @JvmField internal val id: Long = NO_ID, + @JvmField internal val text: String? = null, + @JvmField internal val rightAscension: Angle = 0.0, + @JvmField internal val declination: Angle = 0.0, + @JvmField internal val radius: Angle = 0.0, + @JvmField internal val types: List? = null, + @JvmField internal val magnitudeMin: Double = SkyObject.MAGNITUDE_MIN, + @JvmField internal val magnitudeMax: Double = SkyObject.MAGNITUDE_MAX, + @JvmField internal val constellation: Constellation? = null, + @JvmField internal val ids: LongArray = LongArray(0), + @JvmField internal val lastID: Long = NO_ID, + @JvmField internal val limit: Int = SimbadService.DEFAULT_LIMIT, + @JvmField internal val sortType: SortType = SortType.OID, + @JvmField internal val sortDirection: SortDirection = SortDirection.ASCENDING, ) { enum class SortType { diff --git a/nebulosa-simbad/src/main/kotlin/nebulosa/simbad/SimbadService.kt b/nebulosa-simbad/src/main/kotlin/nebulosa/simbad/SimbadService.kt index 6cbd5aed0..53d4fcfca 100644 --- a/nebulosa-simbad/src/main/kotlin/nebulosa/simbad/SimbadService.kt +++ b/nebulosa-simbad/src/main/kotlin/nebulosa/simbad/SimbadService.kt @@ -94,7 +94,7 @@ class SimbadService( val constellation = SkyObject.constellationFor(rightAscensionJ2000, declinationJ2000) val entity = SimbadEntry( - id, name.joinToString("") { "[$it]" }, magnitude, + id, name.joinToString("|"), magnitude, rightAscensionJ2000, declinationJ2000, type, spType, majorAxis, minorAxis, orientation, pmRA, pmDEC, parallax.mas, radialVelocity, redshift, diff --git a/nebulosa-skycatalog/src/main/kotlin/nebulosa/skycatalog/SkyCatalog.kt b/nebulosa-skycatalog/src/main/kotlin/nebulosa/skycatalog/SkyCatalog.kt index dc52ca6fc..66b6f0157 100644 --- a/nebulosa-skycatalog/src/main/kotlin/nebulosa/skycatalog/SkyCatalog.kt +++ b/nebulosa-skycatalog/src/main/kotlin/nebulosa/skycatalog/SkyCatalog.kt @@ -39,7 +39,7 @@ abstract class SkyCatalog(estimatedSize: Int = 0) : Collection return res } - protected fun notifyLoadFinished() {} + protected fun notifyLoadFinished() = Unit override val size get() = data.size diff --git a/nebulosa-stellarium-protocol/build.gradle.kts b/nebulosa-stellarium-protocol/build.gradle.kts index 618b344a4..28944633f 100644 --- a/nebulosa-stellarium-protocol/build.gradle.kts +++ b/nebulosa-stellarium-protocol/build.gradle.kts @@ -6,6 +6,7 @@ plugins { dependencies { api(project(":nebulosa-erfa")) api(project(":nebulosa-netty")) + compileOnly(project(":nebulosa-indi-device")) implementation(project(":nebulosa-log")) testImplementation(project(":nebulosa-test")) } diff --git a/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumMountHandler.kt b/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumMountHandler.kt index efee5196c..94dbf2558 100644 --- a/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumMountHandler.kt +++ b/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumMountHandler.kt @@ -8,9 +8,5 @@ interface StellariumMountHandler { val declination: Angle - val rightAscensionJ2000: Angle - - val declinationJ2000: Angle - - fun goTo(rightAscension: Angle, declination: Angle, j2000: Boolean = false) + fun goTo(rightAscension: Angle, declination: Angle) } diff --git a/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumMountHandlerAdapter.kt b/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumMountHandlerAdapter.kt new file mode 100644 index 000000000..5ffb625e3 --- /dev/null +++ b/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumMountHandlerAdapter.kt @@ -0,0 +1,17 @@ +package nebulosa.stellarium.protocol + +import nebulosa.indi.device.mount.Mount +import nebulosa.math.Angle + +data class StellariumMountHandlerAdapter(private val mount: Mount) : StellariumMountHandler { + + override val rightAscension + get() = mount.rightAscension + + override val declination + get() = mount.declination + + override fun goTo(rightAscension: Angle, declination: Angle) { + mount.goTo(rightAscension, declination) + } +} diff --git a/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumProtocolHandler.kt b/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumProtocolHandler.kt index b04c4f1f1..7dfc18a48 100644 --- a/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumProtocolHandler.kt +++ b/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumProtocolHandler.kt @@ -12,8 +12,7 @@ internal class StellariumProtocolHandler(private val server: StellariumProtocolS override fun handlerAdded(ctx: ChannelHandlerContext) { client = ctx server.registerCurrentPositionHandler(this) - if (server.j2000) sendCurrentPosition(server.rightAscensionJ2000!!, server.declinationJ2000!!) - else sendCurrentPosition(server.rightAscension!!, server.declination!!) + sendCurrentPosition(server.rightAscension, server.declination) LOG.info("client connected. address={}", ctx.channel().remoteAddress()) } @@ -29,8 +28,9 @@ internal class StellariumProtocolHandler(private val server: StellariumProtocolS } override fun channelRead(ctx: ChannelHandlerContext, msg: Any) { - val message = msg as StellariumProtocolMessage.Goto - server.goTo(message.rightAscension, message.declination) + when (msg) { + is StellariumProtocolMessage.Goto -> server.goTo(msg.rightAscension, msg.declination) + } } @Suppress("OVERRIDE_DEPRECATION") diff --git a/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumProtocolServer.kt b/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumProtocolServer.kt index b815be467..297892213 100644 --- a/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumProtocolServer.kt +++ b/nebulosa-stellarium-protocol/src/main/kotlin/nebulosa/stellarium/protocol/StellariumProtocolServer.kt @@ -64,26 +64,19 @@ import java.util.concurrent.atomic.AtomicReference * @see Protocol * @see Stellarium Implementation */ -class StellariumProtocolServer( +data class StellariumProtocolServer( override val host: String = "0.0.0.0", override val port: Int = 10001, - val j2000: Boolean = false, -) : NettyServer(), CurrentPositionHandler { +) : NettyServer(), CurrentPositionHandler, StellariumMountHandler { private val stellariumMountHandler = AtomicReference() private val currentPositionHandlers = LinkedHashSet() - val rightAscension: Angle? - get() = stellariumMountHandler.get()?.rightAscension + override val rightAscension + get() = stellariumMountHandler.get()?.rightAscension ?: 0.0 - val declination: Angle? - get() = stellariumMountHandler.get()?.declination - - val rightAscensionJ2000: Angle? - get() = stellariumMountHandler.get()?.rightAscensionJ2000 - - val declinationJ2000: Angle? - get() = stellariumMountHandler.get()?.declinationJ2000 + override val declination + get() = stellariumMountHandler.get()?.declination ?: 0.0 override val channelInitialzer = object : ChannelInitializer() { @@ -110,6 +103,7 @@ class StellariumProtocolServer( } fun attachMountHandler(handler: StellariumMountHandler) { + require(handler !== this) { "cannot attach this server" } stellariumMountHandler.set(handler) } @@ -118,8 +112,8 @@ class StellariumProtocolServer( } @Synchronized - internal fun goTo(rightAscension: Angle, declination: Angle) { - stellariumMountHandler.get()?.goTo(rightAscension, declination, j2000) + override fun goTo(rightAscension: Angle, declination: Angle) { + stellariumMountHandler.get()?.goTo(rightAscension, declination) } override fun close() { diff --git a/nebulosa-stellarium-protocol/src/test/kotlin/StellariumProtocolServerTest.kt b/nebulosa-stellarium-protocol/src/test/kotlin/StellariumProtocolServerTest.kt index c4b05304e..e84d0789d 100644 --- a/nebulosa-stellarium-protocol/src/test/kotlin/StellariumProtocolServerTest.kt +++ b/nebulosa-stellarium-protocol/src/test/kotlin/StellariumProtocolServerTest.kt @@ -11,8 +11,6 @@ class StellariumProtocolServerTest { override var rightAscension = "05 15 07".hours override var declination = "25 26 03".deg - override var rightAscensionJ2000 = "05 15 07".hours - override var declinationJ2000 = "25 26 03".deg @JvmStatic fun main(args: Array) { @@ -32,14 +30,9 @@ class StellariumProtocolServerTest { Thread.currentThread().join() } - override fun goTo(rightAscension: Angle, declination: Angle, j2000: Boolean) { - if (j2000) { - rightAscensionJ2000 = rightAscension - declinationJ2000 = declination - } else { - this.rightAscension = rightAscension - this.declination = declination - } + override fun goTo(rightAscension: Angle, declination: Angle) { + this.rightAscension = rightAscension + this.declination = declination } } } diff --git a/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt b/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt index 891413196..ece585232 100644 --- a/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt +++ b/nebulosa-watney/src/main/kotlin/nebulosa/watney/plate/solving/WatneyPlateSolver.kt @@ -654,7 +654,7 @@ data class WatneyPlateSolver( return if (filtered.size >= 8) { filtered } else { - LOG.info("Not enough matches to perform filtering, with so few matches assuming they're good") + LOG.info("not enough matches to perform filtering, with so few matches assuming they're good") matches } } diff --git a/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt b/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt index 28fdd9383..18e21d869 100644 --- a/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt +++ b/nebulosa-wcs/src/test/kotlin/LibWCSTest.kt @@ -8,6 +8,7 @@ import nebulosa.math.formatHMS import nebulosa.math.formatSignedDMS import nebulosa.test.NonGitHubOnlyCondition import nebulosa.wcs.WCS +import java.nio.file.Path import kotlin.random.Random // https://www.atnf.csiro.au/people/mcalabre/WCS/example_data.html @@ -53,7 +54,7 @@ class LibWCSTest : StringSpec() { } private fun readHeaderFromFits(name: String): ReadableHeader { - return "src/test/resources/$name.fits".fits().use { it.first!!.header } + return Path.of("src/test/resources/$name.fits").fits().use { it.first!!.header } } companion object { diff --git a/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfFormat.kt b/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfFormat.kt index 6ce39ef86..3db2be341 100644 --- a/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfFormat.kt +++ b/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfFormat.kt @@ -1,15 +1,18 @@ package nebulosa.xisf +import nebulosa.fits.Bitpix import nebulosa.fits.ValueType import nebulosa.fits.bitpix import nebulosa.fits.frame import nebulosa.image.format.Hdu import nebulosa.image.format.ImageFormat import nebulosa.image.format.ImageHdu +import nebulosa.image.format.ImageModifier import nebulosa.io.* import nebulosa.xisf.XisfMonolithicFileHeader.* import nebulosa.xml.escapeXml import okio.Buffer +import okio.BufferedSource import okio.Sink import okio.Timeout import java.io.ByteArrayInputStream @@ -24,10 +27,12 @@ import kotlin.math.min */ data object XisfFormat : ImageFormat { + const val SIGNATURE = "XISF0100" + override fun read(source: SeekableSource): List { return Buffer().use { buffer -> - source.read(buffer, 8) // XISF0100 - check(buffer.readString(Charsets.US_ASCII) == "XISF0100") { "invalid magic bytes" } + source.read(buffer, 8) + check(buffer.readSignature() == SIGNATURE) { "invalid signature" } // Header length (4) + reserved (4) source.read(buffer, 8) @@ -53,9 +58,11 @@ data object XisfFormat : ImageFormat { } } - override fun write(sink: Sink, hdus: Iterable>) { + override fun write(sink: Sink, hdus: Iterable>, modifier: ImageModifier) { + val bitpix = modifier.bitpix() + Buffer().use { buffer -> - val headerSize = writeHeader(buffer, hdus) + val headerSize = writeHeader(buffer, hdus, bitpix) val byteCount = buffer.readAll(sink) val remainingBytes = headerSize - byteCount @@ -65,8 +72,7 @@ data object XisfFormat : ImageFormat { for (hdu in hdus) { if (hdu is ImageHdu) { - val bitpix = hdu.header.bitpix - val sampleFormat = SampleFormat.from(bitpix) + val sampleFormat = SampleFormat.from(bitpix ?: hdu.header.bitpix) if (hdu.isMono) { hdu.data.red.writeTo(buffer, sink, sampleFormat) @@ -80,7 +86,7 @@ data object XisfFormat : ImageFormat { } } - private fun writeHeader(buffer: Buffer, hdus: Iterable>, initialHeaderSize: Int = 4096): Int { + private fun writeHeader(buffer: Buffer, hdus: Iterable>, bitpix: Bitpix? = null, initialHeaderSize: Int = 4096): Int { buffer.clear() buffer.writeString(MAGIC_HEADER, Charsets.US_ASCII) @@ -94,10 +100,9 @@ data object XisfFormat : ImageFormat { val header = hdu.header val colorSpace = if (hdu.isMono) ColorSpace.GRAY else ColorSpace.RGB val imageType = ImageType.parse(header.frame ?: "Light") ?: ImageType.LIGHT - val bitpix = hdu.header.bitpix - val sampleFormat = SampleFormat.from(bitpix) + val sampleFormat = SampleFormat.from(bitpix ?: hdu.header.bitpix) val imageSize = hdu.width * hdu.height * hdu.numberOfChannels * sampleFormat.byteLength - val extra = if (bitpix.code < 0) " bounds=\"0:1\"" else "" + val extra = if (sampleFormat.bitpix.code < 0) " bounds=\"0:1\"" else "" IMAGE_START_TAG .format( @@ -125,7 +130,8 @@ data object XisfFormat : ImageFormat { } for (keyword in header) { - val value = if (keyword.isStringType) "'${keyword.value.escapeXml()}'" else keyword.value + val value = if (keyword.key == sampleFormat.bitpix.key) sampleFormat.bitpix.value + else if (keyword.isStringType) "'${keyword.value.escapeXml()}'" else keyword.value buffer.writeUtf8(FITS_KEYWORD_TAG.format(keyword.key, value, keyword.comment.escapeXml())) } } @@ -138,7 +144,7 @@ data object XisfFormat : ImageFormat { val remainingBytes = initialHeaderSize - size if (remainingBytes < 0) { - return writeHeader(buffer, hdus, initialHeaderSize * 2) + return writeHeader(buffer, hdus, bitpix, initialHeaderSize * 2) } val headerSize = size - 16 @@ -154,6 +160,9 @@ data object XisfFormat : ImageFormat { return initialHeaderSize } + @JvmStatic + fun BufferedSource.readSignature() = readString(8L, Charsets.US_ASCII) + @JvmStatic internal fun Buffer.readPixel(format: SampleFormat, byteOrder: ByteOrder): Float { return when (format) { diff --git a/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfHelper.kt b/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfHelper.kt index d48ad55b1..cc69a0293 100644 --- a/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfHelper.kt +++ b/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfHelper.kt @@ -2,11 +2,16 @@ package nebulosa.xisf +import nebulosa.xisf.XisfFormat.readSignature +import okio.buffer +import okio.source import java.io.File import java.nio.file.Path -inline fun String.xisf() = XisfPath(this).also(XisfPath::read) - inline fun Path.xisf() = XisfPath(this).also(XisfPath::read) inline fun File.xisf() = XisfPath(this).also(XisfPath::read) + +inline fun File.isXisf() = source().buffer().use { it.readSignature() == XisfFormat.SIGNATURE } + +inline fun Path.isXisf() = source().buffer().use { it.readSignature() == XisfFormat.SIGNATURE } diff --git a/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfPath.kt b/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfPath.kt index 2581951c1..587f6fa44 100644 --- a/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfPath.kt +++ b/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfPath.kt @@ -1,20 +1,20 @@ package nebulosa.xisf -import nebulosa.io.seekableSink -import nebulosa.io.seekableSource +import nebulosa.io.sink +import nebulosa.io.source import java.io.Closeable import java.io.File +import java.io.RandomAccessFile import java.nio.file.Path data class XisfPath(val path: Path) : Xisf(), Closeable { - private val source = path.seekableSource() - private val sink = path.seekableSink() + private val file = RandomAccessFile(path.toFile(), "rw") + private val source = file.source() + private val sink = file.sink() constructor(file: File) : this(file.toPath()) - constructor(path: String) : this(Path.of(path)) - fun read() { read(source) } @@ -26,5 +26,6 @@ data class XisfPath(val path: Path) : Xisf(), Closeable { override fun close() { source.close() sink.close() + file.close() } } diff --git a/nebulosa-xisf/src/test/kotlin/XisfFormatTest.kt b/nebulosa-xisf/src/test/kotlin/XisfFormatTest.kt index 289992b3c..79ac306e4 100644 --- a/nebulosa-xisf/src/test/kotlin/XisfFormatTest.kt +++ b/nebulosa-xisf/src/test/kotlin/XisfFormatTest.kt @@ -1,4 +1,6 @@ import io.kotest.engine.spec.tempfile +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.floats.shouldBeExactly import io.kotest.matchers.ints.shouldBeExactly @@ -12,10 +14,15 @@ import nebulosa.io.seekableSink import nebulosa.io.seekableSource import nebulosa.test.AbstractFitsAndXisfTest import nebulosa.xisf.XisfFormat +import nebulosa.xisf.isXisf class XisfFormatTest : AbstractFitsAndXisfTest() { init { + "should be xisf format" { + NGC3344_COLOR_8_FITS.isXisf().shouldBeFalse() + M82_COLOR_16_XISF.isXisf().shouldBeTrue() + } "mono:planar:8" { val source = closeAfterEach(M82_MONO_8_XISF.seekableSource()) val hdus = XisfFormat.read(source) diff --git a/settings.gradle.kts b/settings.gradle.kts index d75a40a9b..2996b4b24 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,28 +15,27 @@ dependencyResolutionManagement { library("okio", "com.squareup.okio:okio:3.9.0") library("okhttp", "com.squareup.okhttp3:okhttp:4.12.0") library("okhttp-logging", "com.squareup.okhttp3:logging-interceptor:4.12.0") - library("jackson-core", "com.fasterxml.jackson.core:jackson-databind:2.17.0") - library("jackson-jsr310", "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0") - library("jackson-kt", "com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0") + library("jackson-core", "com.fasterxml.jackson.core:jackson-databind:2.17.1") + library("jackson-jsr310", "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1") + library("jackson-kt", "com.fasterxml.jackson.module:jackson-module-kotlin:2.17.1") library("retrofit", "com.squareup.retrofit2:retrofit:2.11.0") library("retrofit-jackson", "com.squareup.retrofit2:converter-jackson:2.11.0") library("rx", "io.reactivex.rxjava3:rxjava:3.1.8") - library("logback", "ch.qos.logback:logback-classic:1.5.3") + library("logback", "ch.qos.logback:logback-classic:1.5.6") library("eventbus", "org.greenrobot:eventbus-java:3.3.1") - library("netty-transport", "io.netty:netty-transport:4.1.108.Final") - library("netty-codec", "io.netty:netty-codec:4.1.108.Final") + library("netty-transport", "io.netty:netty-transport:4.1.109.Final") + library("netty-codec", "io.netty:netty-codec:4.1.109.Final") library("xml", "com.fasterxml:aalto-xml:1.3.2") library("csv", "de.siegmar:fastcsv:3.1.0") library("apache-lang3", "org.apache.commons:commons-lang3:3.14.0") - library("apache-codec", "commons-codec:commons-codec:1.16.1") + library("apache-codec", "commons-codec:commons-codec:1.17.0") library("apache-collections", "org.apache.commons:commons-collections4:4.4") library("apache-math", "org.apache.commons:commons-math3:3.6.1") library("apache-numbers-complex", "org.apache.commons:commons-numbers-complex:1.1") - library("oshi", "com.github.oshi:oshi-core:6.5.0") - library("timeshape", "net.iakovlev:timeshape:2022g.17") + library("oshi", "com.github.oshi:oshi-core:6.6.0") library("jna", "net.java.dev.jna:jna:5.14.0") - library("kotest-assertions-core", "io.kotest:kotest-assertions-core:5.8.1") - library("kotest-runner-junit5", "io.kotest:kotest-runner-junit5:5.8.1") + library("kotest-assertions-core", "io.kotest:kotest-assertions-core:5.9.0") + library("kotest-runner-junit5", "io.kotest:kotest-runner-junit5:5.9.0") bundle("kotest", listOf("kotest-assertions-core", "kotest-runner-junit5")) bundle("netty", listOf("netty-transport", "netty-codec")) bundle("jackson", listOf("jackson-core", "jackson-jsr310", "jackson-kt")) @@ -54,7 +53,6 @@ include(":nebulosa-astap") include(":nebulosa-astrobin-api") include(":nebulosa-astrometrynet") include(":nebulosa-astrometrynet-jna") -include(":nebulosa-batch-processing") include(":nebulosa-common") include(":nebulosa-constants") include(":nebulosa-curve-fitting")