├── .github ├── ISSUE_TEMPLATE │ ├── BUG-REPORT.yml │ ├── FEATURE-REQUEST.yml │ └── config.yml └── workflows │ ├── build-on-prerelease.yaml │ ├── build-on-release.yaml │ ├── test-python-code.yaml │ └── test-typescript-code.yaml ├── .gitignore ├── LICENSE.md ├── README.md ├── config ├── astronomy_dataset_de421.bsp ├── config.default.json ├── helios.config.default.json ├── tum_enclosure.config.default.json └── upload.config.default.json ├── logs ├── .gitkeep ├── activity │ └── .gitkeep ├── archive │ └── .gitkeep ├── helios-autoexposure │ └── .gitkeep ├── helios │ └── .gitkeep └── tum-enclosure │ └── .gitkeep ├── packages ├── __init__.py ├── cli │ ├── __init__.py │ ├── commands │ │ ├── __init__.py │ │ ├── config.py │ │ ├── core.py │ │ ├── logs.py │ │ ├── remove_filelocks.py │ │ ├── state.py │ │ ├── test.py │ │ └── tum_enclosure.py │ └── main.py ├── core │ ├── __init__.py │ ├── interfaces │ │ ├── __init__.py │ │ ├── activity_history.py │ │ ├── em27_interface.py │ │ ├── state_interface.py │ │ └── tum_enclosure_interface.py │ ├── main.py │ ├── threads │ │ ├── __init__.py │ │ ├── abstract_thread.py │ │ ├── camtracker_thread.py │ │ ├── cas_thread.py │ │ ├── helios_thread.py │ │ ├── opus_thread.py │ │ ├── system_monitor_thread.py │ │ ├── tum_enclosure_thread.py │ │ └── upload_thread.py │ ├── types │ │ ├── __init__.py │ │ ├── activity_history.py │ │ ├── config.py │ │ ├── enclosures │ │ │ ├── __init__.py │ │ │ └── tum_enclosure.py │ │ ├── plc_specification.py │ │ └── state.py │ └── utils │ │ ├── __init__.py │ │ ├── astronomy.py │ │ ├── exception_email_client.py │ │ ├── functions.py │ │ ├── helios_image_processing.py │ │ ├── logger.py │ │ ├── old_helios_image_processing.py │ │ └── tum_enclosure_logger.py ├── docs │ ├── .gitignore │ ├── .node-version │ ├── babel.config.js │ ├── bun.lockb │ ├── docs │ │ ├── contributor-guide │ │ │ ├── _category_.json │ │ │ ├── becoming-a-contributor.mdx │ │ │ └── code-of-conduct.mdx │ │ ├── developer-guide │ │ │ ├── _category_.json │ │ │ ├── architecture.mdx │ │ │ ├── external-interfaces.mdx │ │ │ ├── internal-interfaces.mdx │ │ │ ├── repository-organization.mdx │ │ │ ├── running-pyra-manually.mdx │ │ │ └── testing-and-ci.mdx │ │ ├── intro │ │ │ ├── _category_.json │ │ │ ├── contact.mdx │ │ │ └── overview.mdx │ │ └── user-guide │ │ │ ├── _category_.json │ │ │ ├── automatic-peak-positioning.mdx │ │ │ ├── command-line-interface.mdx │ │ │ ├── faq.mdx │ │ │ ├── functionality.mdx │ │ │ ├── measurement-modes.mdx │ │ │ ├── setup.mdx │ │ │ ├── tum-enclosure-and-helios.mdx │ │ │ ├── upload.mdx │ │ │ └── usage-overview.mdx │ ├── docusaurus.config.js │ ├── package.json │ ├── postcss.config.js │ ├── sidebars.js │ ├── src │ │ ├── components │ │ │ ├── github-label.tsx │ │ │ └── track-on-github.tsx │ │ ├── css │ │ │ ├── components │ │ │ │ └── navbar.scss │ │ │ ├── custom.scss │ │ │ └── fonts.css │ │ └── pages │ │ │ ├── index.module.css │ │ │ └── index.tsx │ ├── static │ │ ├── .nojekyll │ │ ├── drawings │ │ │ ├── exaclidraw-files │ │ │ │ ├── architecture.excalidraw │ │ │ │ └── setup.excalidraw │ │ │ └── exports │ │ │ │ ├── package-architecture.png │ │ │ │ ├── setup.png │ │ │ │ └── state-architecture.png │ │ ├── fonts │ │ │ ├── rubik-v26-latin_latin-ext-500.woff │ │ │ ├── rubik-v26-latin_latin-ext-500.woff2 │ │ │ ├── rubik-v26-latin_latin-ext-500italic.woff │ │ │ ├── rubik-v26-latin_latin-ext-500italic.woff2 │ │ │ ├── rubik-v26-latin_latin-ext-600.woff │ │ │ ├── rubik-v26-latin_latin-ext-600.woff2 │ │ │ ├── rubik-v26-latin_latin-ext-600italic.woff │ │ │ ├── rubik-v26-latin_latin-ext-600italic.woff2 │ │ │ ├── rubik-v26-latin_latin-ext-700.woff │ │ │ ├── rubik-v26-latin_latin-ext-700.woff2 │ │ │ ├── rubik-v26-latin_latin-ext-700italic.woff │ │ │ ├── rubik-v26-latin_latin-ext-700italic.woff2 │ │ │ ├── rubik-v26-latin_latin-ext-800.woff │ │ │ ├── rubik-v26-latin_latin-ext-800.woff2 │ │ │ ├── rubik-v26-latin_latin-ext-800italic.woff │ │ │ ├── rubik-v26-latin_latin-ext-800italic.woff2 │ │ │ ├── rubik-v26-latin_latin-ext-italic.woff │ │ │ ├── rubik-v26-latin_latin-ext-italic.woff2 │ │ │ ├── rubik-v26-latin_latin-ext-regular.woff │ │ │ └── rubik-v26-latin_latin-ext-regular.woff2 │ │ ├── img │ │ │ ├── docs │ │ │ │ ├── cli-config-update-example.png │ │ │ │ ├── config-file-reload.png │ │ │ │ ├── email-config.png │ │ │ │ ├── email-error-occured.png │ │ │ │ ├── email-errors-resolved.png │ │ │ │ ├── environment-path-manual-1.png │ │ │ │ ├── environment-path-manual-2.png │ │ │ │ ├── environment-path-manual-3.png │ │ │ │ ├── environment-path-manual-4.png │ │ │ │ ├── helios-example-image-bad-processed.jpg │ │ │ │ ├── helios-example-image-bad-raw.jpg │ │ │ │ ├── helios-example-image-good-processed.jpg │ │ │ │ ├── helios-example-image-good-raw.jpg │ │ │ │ ├── helios-hardware.png │ │ │ │ ├── jia-profile-image.jpg │ │ │ │ ├── logs-tab.png │ │ │ │ ├── measurement-modes.png │ │ │ │ ├── moritz-profile-image.jpg │ │ │ │ ├── muccnet-image-roof.jpg │ │ │ │ ├── overview-tab.png │ │ │ │ ├── patrick-profile-image.jpg │ │ │ │ ├── pyra-cli-path-request.png │ │ │ │ ├── python-310-path-automatic.png │ │ │ │ ├── triggers-config.png │ │ │ │ └── who-made-this-meme.png │ │ │ ├── favicon.ico │ │ │ ├── icons │ │ │ │ └── icon-cheveron-up.svg │ │ │ └── logo.svg │ │ └── logo │ │ │ ├── pyra-4-icon-1024.png │ │ │ ├── pyra-4-icon-256.png │ │ │ ├── pyra-4-icon-512.png │ │ │ └── pyra-4-icon.svg │ ├── tailwind.config.js │ └── tsconfig.json └── ui │ ├── .env.example │ ├── .gitignore │ ├── bun.lockb │ ├── components.json │ ├── index.html │ ├── package.json │ ├── postcss.config.js │ ├── src-tauri │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── build.rs │ ├── capabilities │ │ └── migrated.json │ ├── gen │ │ └── schemas │ │ │ ├── acl-manifests.json │ │ │ ├── capabilities.json │ │ │ ├── desktop-schema.json │ │ │ ├── macOS-schema.json │ │ │ └── windows-schema.json │ ├── icons │ │ ├── 128x128.png │ │ ├── 128x128@2x.png │ │ ├── 32x32.png │ │ ├── Square107x107Logo.png │ │ ├── Square142x142Logo.png │ │ ├── Square150x150Logo.png │ │ ├── Square284x284Logo.png │ │ ├── Square30x30Logo.png │ │ ├── Square310x310Logo.png │ │ ├── Square44x44Logo.png │ │ ├── Square71x71Logo.png │ │ ├── Square89x89Logo.png │ │ ├── StoreLogo.png │ │ ├── icon.icns │ │ ├── icon.ico │ │ └── icon.png │ ├── src │ │ └── main.rs │ └── tauri.conf.json │ ├── src │ ├── assets │ │ ├── green-tum-logo_1024x.icns │ │ ├── green-tum-logo_1024x.png │ │ ├── icons.tsx │ │ └── index.ts │ ├── components │ │ ├── configuration │ │ │ ├── index.ts │ │ │ ├── rows │ │ │ │ ├── config-element-line.tsx │ │ │ │ ├── config-element-note.tsx │ │ │ │ ├── config-element-text.tsx │ │ │ │ ├── config-element-time.tsx │ │ │ │ ├── config-element-toggle.tsx │ │ │ │ └── labeled-row.tsx │ │ │ ├── saving-overlay.tsx │ │ │ └── sections │ │ │ │ ├── config-section-camtracker.tsx │ │ │ │ ├── config-section-error-email.tsx │ │ │ │ ├── config-section-general.tsx │ │ │ │ ├── config-section-helios.tsx │ │ │ │ ├── config-section-measurement-triggers.tsx │ │ │ │ ├── config-section-opus.tsx │ │ │ │ ├── config-section-tum-enclosure.tsx │ │ │ │ └── config-section-upload.tsx │ │ ├── essential │ │ │ ├── button.tsx │ │ │ ├── index.ts │ │ │ ├── live-switch.tsx │ │ │ ├── log-line.tsx │ │ │ ├── numeric-button.tsx │ │ │ ├── ping.tsx │ │ │ ├── previous-value.tsx │ │ │ ├── spinner.tsx │ │ │ ├── text-input.tsx │ │ │ └── toggle.tsx │ │ ├── index.ts │ │ ├── overview │ │ │ ├── activity-plot.tsx │ │ │ ├── index.ts │ │ │ ├── measurement-decision.tsx │ │ │ ├── pyra-core-status.tsx │ │ │ └── system-state.tsx │ │ ├── structural │ │ │ ├── dashboard.tsx │ │ │ ├── disconnected-screen.tsx │ │ │ ├── header.tsx │ │ │ └── index.ts │ │ └── ui │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── select.tsx │ │ │ └── separator.tsx │ ├── custom-types.ts │ ├── index.tsx │ ├── lib │ │ └── utils.ts │ ├── main.tsx │ ├── styles │ │ └── index.css │ ├── tabs │ │ ├── configuration-tab.tsx │ │ ├── control-tab.tsx │ │ ├── index.ts │ │ ├── log-tab.tsx │ │ └── overview-tab.tsx │ ├── utils │ │ ├── fetch-utils │ │ │ ├── backend.ts │ │ │ ├── get-file-content.ts │ │ │ ├── get-project-dir-path.ts │ │ │ ├── index.ts │ │ │ └── use-command.ts │ │ ├── functions.ts │ │ ├── index.ts │ │ └── zustand-utils │ │ │ ├── activity-zustand.ts │ │ │ ├── config-zustand.ts │ │ │ ├── core-process-zustand.ts │ │ │ ├── core-state-zustand.ts │ │ │ └── logs-zustand.ts │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.mjs ├── pdm.lock ├── pyproject.toml ├── run_pyra_core.py ├── scripts ├── run_headless_helios_thread.py ├── run_headless_upload_thread.py └── sync_version_numbers.py └── tests ├── __init__.py ├── cli ├── __init__.py ├── test_cli_config.py └── test_cli_core.py ├── fixtures.py ├── integration ├── __init__.py ├── test_camtracker_connection.py ├── test_config.py ├── test_emailing.py ├── test_opus_connection.py └── test_uploading.py ├── repository ├── README.md ├── __init__.py ├── test_default_config.py ├── test_static_types.py └── test_version_numbers.py └── utils ├── __init__.py └── test_astronomy.py /.github/ISSUE_TEMPLATE/BUG-REPORT.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[BUG]: " 4 | labels: ["needs-triage"] 5 | assignees: 6 | - dostuffthatmatters 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | **🍀 Thanks for taking the time to fill out this bug report!** 12 | - type: textarea 13 | id: description 14 | attributes: 15 | label: Descriptions 16 | description: Describe the bug. Add logs, screenshots, etc. Markdown syntax supported. 17 | validations: 18 | required: true 19 | - type: input 20 | id: pyra-version 21 | attributes: 22 | label: Pyra Version 23 | placeholder: 4.X.Y 24 | validations: 25 | required: true 26 | - type: input 27 | id: opus-version 28 | attributes: 29 | label: OPUS Version 30 | placeholder: 7.8.* / 8.2.* / ... 31 | validations: 32 | required: false 33 | - type: input 34 | id: camtracker-version 35 | attributes: 36 | label: CamTracker Version 37 | placeholder: 3.9.* / ... 38 | validations: 39 | required: false 40 | - type: input 41 | id: windows-version 42 | attributes: 43 | label: Windows Version 44 | placeholder: 10 / ... 45 | validations: 46 | required: false 47 | - type: textarea 48 | id: configuration 49 | attributes: 50 | label: Your `config.json` file 51 | description: This will be automatically formatted into code, so no need for backticks. 52 | render: json 53 | validations: 54 | required: false 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: "[FEATURE]: " 4 | labels: ["needs-triage"] 5 | assignees: 6 | - dostuffthatmatters 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | **🍀 Thanks for taking the time to submit your feature idea!** 12 | - type: textarea 13 | id: description 14 | attributes: 15 | label: Description 16 | description: Describe your feature idea. Does it solve an existing problem? Feel free to add images or drawings. 17 | validations: 18 | required: true 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Pyra Documentation 4 | url: https://pyra.esm.ei.tum.de/docs 5 | about: Maybe your question is already answered in the documentation? 6 | -------------------------------------------------------------------------------- /.github/workflows/build-on-prerelease.yaml: -------------------------------------------------------------------------------- 1 | name: "build-on-prerelease" 2 | on: 3 | push: 4 | branches: 5 | - prerelease 6 | 7 | defaults: 8 | run: 9 | working-directory: packages/ui 10 | 11 | jobs: 12 | publish-tauri: 13 | runs-on: windows-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Read version from package.json 18 | id: read_version 19 | uses: actions/github-script@v7 20 | with: 21 | script: | 22 | const fs = require('fs'); 23 | const path = 'packages/ui/package.json'; 24 | const packageJson = JSON.parse(fs.readFileSync(path, 'utf8')); 25 | core.setOutput('version', packageJson.version); 26 | 27 | - name: Print version number 28 | run: echo "Building UI for PYRA version ${{ steps.read_version.outputs.version }}" 29 | 30 | - name: set up node 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: 20 34 | 35 | - name: set up bun 36 | uses: oven-sh/setup-bun@v2 37 | 38 | - name: set up rust 39 | uses: actions-rust-lang/setup-rust-toolchain@v1 40 | with: 41 | toolchain: stable 42 | cache-workspaces: "./packages/ui/src-tauri -> target" 43 | 44 | - name: tauri build cache 45 | uses: actions/cache@v4 46 | with: 47 | path: packages/ui/src-tauri/target 48 | key: ${{ runner.os }}-tauri-cache 49 | 50 | - name: install dependencies 51 | run: bun install 52 | 53 | - name: build image 54 | run: bun run tauri build 55 | 56 | - name: create draft release 57 | uses: softprops/action-gh-release@v2 58 | with: 59 | tag_name: v${{ steps.read_version.outputs.version }}-prerelease 60 | name: v${{ steps.read_version.outputs.version }}-prerelease (generated by CI) 61 | draft: true 62 | prerelease: true 63 | body: "TODO: description" 64 | target_commitish: prerelease 65 | files: | 66 | packages/ui/src-tauri/target/release/bundle/msi/Pyra UI_${{ steps.read_version.outputs.version }}_x64_en-US.msi 67 | packages/ui/src-tauri/target/release/bundle/nsis/Pyra UI_${{ steps.read_version.outputs.version }}_x64-setup.exe 68 | -------------------------------------------------------------------------------- /.github/workflows/build-on-release.yaml: -------------------------------------------------------------------------------- 1 | name: "build-on-release" 2 | on: 3 | push: 4 | branches: 5 | - release 6 | 7 | defaults: 8 | run: 9 | working-directory: packages/ui 10 | 11 | jobs: 12 | publish-tauri: 13 | runs-on: windows-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Read version from package.json 18 | id: read_version 19 | uses: actions/github-script@v7 20 | with: 21 | script: | 22 | const fs = require('fs'); 23 | const path = 'packages/ui/package.json'; 24 | const packageJson = JSON.parse(fs.readFileSync(path, 'utf8')); 25 | core.setOutput('version', packageJson.version); 26 | 27 | - name: Print version number 28 | run: echo "Building UI for PYRA version ${{ steps.read_version.outputs.version }}" 29 | 30 | - name: Check if tag exists 31 | uses: actions/github-script@v7 32 | with: 33 | script: | 34 | const version = '${{ steps.read_version.outputs.version }}'; 35 | const tag = `v${version}`; 36 | const { data: tags } = await github.rest.repos.listTags({ 37 | owner: context.repo.owner, 38 | repo: context.repo.repo, 39 | }); 40 | if (tags.some(t => t.name === tag)) { 41 | core.setFailed(`Tag ${tag} already exists.`); 42 | } else { 43 | console.log(`Tag ${tag} does not exist.`); 44 | } 45 | 46 | - name: set up node 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: 20 50 | 51 | - name: set up bun 52 | uses: oven-sh/setup-bun@v2 53 | 54 | - name: set up rust 55 | uses: actions-rust-lang/setup-rust-toolchain@v1 56 | with: 57 | toolchain: stable 58 | cache-workspaces: "./packages/ui/src-tauri -> target" 59 | 60 | - name: tauri build cache 61 | uses: actions/cache@v4 62 | with: 63 | path: packages/ui/src-tauri/target 64 | key: ${{ runner.os }}-tauri-cache 65 | 66 | - name: install dependencies 67 | run: bun install 68 | 69 | - name: build image 70 | run: bun run tauri build 71 | 72 | - name: create draft release 73 | uses: softprops/action-gh-release@v2 74 | with: 75 | tag_name: v${{ steps.read_version.outputs.version }} 76 | name: v${{ steps.read_version.outputs.version }} (generated by CI) 77 | draft: true 78 | prerelease: true 79 | body: "TODO: description" 80 | target_commitish: release 81 | files: | 82 | packages/ui/src-tauri/target/release/bundle/msi/Pyra UI_${{ steps.read_version.outputs.version }}_x64_en-US.msi 83 | packages/ui/src-tauri/target/release/bundle/nsis/Pyra UI_${{ steps.read_version.outputs.version }}_x64-setup.exe 84 | -------------------------------------------------------------------------------- /.github/workflows/test-python-code.yaml: -------------------------------------------------------------------------------- 1 | name: "test-python-code" 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths-ignore: 7 | - "packages/core/ui/**" 8 | - "packages/core/docs/**" 9 | - ".gitignore" 10 | - "README.md" 11 | - "LICENSE.md" 12 | - "netlify.toml" 13 | - ".github/workflows/*.yaml" 14 | - "!.github/workflows/test-python-code.yaml" 15 | pull_request: 16 | branches: 17 | - main 18 | - integration-* 19 | paths-ignore: 20 | - "packages/core/ui/**" 21 | - "packages/core/docs/**" 22 | - ".gitignore" 23 | - "README.md" 24 | - "LICENSE.md" 25 | - "netlify.toml" 26 | - ".github/workflows/*.yaml" 27 | - "!.github/workflows/test-python-code.yaml" 28 | jobs: 29 | test: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Check out repository 33 | uses: actions/checkout@v4 34 | - name: Set up Python 3.10.11 35 | uses: actions/setup-python@v5 36 | with: 37 | python-version: "3.10.11" 38 | cache: "pip" 39 | 40 | - name: Install dependencies 41 | run: pip install ".[dev]" 42 | 43 | - name: Run Pytests 44 | run: | 45 | pytest -m "ci" --cov=packages tests 46 | coverage report 47 | -------------------------------------------------------------------------------- /.github/workflows/test-typescript-code.yaml: -------------------------------------------------------------------------------- 1 | name: "test-typescript-code" 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - "packages/ui/**" 8 | - ".github/workflows/test-typescript-code.yaml" 9 | pull_request: 10 | branches: 11 | - main 12 | - integration-* 13 | paths: 14 | - "packages/ui/**" 15 | - ".github/workflows/test-typescript-code.yaml" 16 | 17 | defaults: 18 | run: 19 | working-directory: packages/ui 20 | 21 | jobs: 22 | test: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Check out repository 26 | uses: actions/checkout@v4 27 | 28 | - name: Set up Bun 29 | uses: oven-sh/setup-bun@v2 30 | - name: Set up NodeJS 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: 20 34 | 35 | - name: Install dependencies 36 | run: bun install 37 | 38 | - name: Build frontend 39 | run: bun run build 40 | -------------------------------------------------------------------------------- /config/astronomy_dataset_de421.bsp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/config/astronomy_dataset_de421.bsp -------------------------------------------------------------------------------- /config/config.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": { 3 | "version": "4.2.2", 4 | "seconds_per_core_iteration": 30, 5 | "test_mode": false, 6 | "station_id": "...", 7 | "min_sun_elevation": 5 8 | }, 9 | "opus": { 10 | "em27_ip": "10.10.0.1", 11 | "executable_path": "C:\\Program Files (x86)\\Bruker\\OPUS_7.8.44\\opus.exe", 12 | "experiment_path": "C:\\Users\\Public\\Documents\\Bruker\\OPUS_7.8.44\\XPM\\experiment.xpm", 13 | "macro_path": "C:\\Users\\Public\\Documents\\Bruker\\OPUS_7.8.44\\Macro\\macro.mtx", 14 | "username": "Default", 15 | "password": "...", 16 | "automatic_peak_positioning": false, 17 | "automatic_peak_positioning_dcmin": 0.02, 18 | "interferogram_path": "" 19 | }, 20 | "camtracker": { 21 | "config_path": "C:\\Users\\Public\\Documents\\Bruker\\camtracker_3_9_1_0\\CamTrackerConfig.txt", 22 | "executable_path": "C:\\Users\\Public\\Documents\\Bruker\\camtracker_3_9_1_0\\CamTracker_3_9.exe", 23 | "working_directory_path": "C:\\Users\\Public\\Documents\\Bruker\\camtracker_3_9_1_0", 24 | "learn_az_elev_path": "C:\\Users\\Public\\Documents\\Bruker\\camtracker_3_9_1_0\\LEARN_Az_Elev.dat", 25 | "sun_intensity_path": "C:\\Users\\Public\\Documents\\Bruker\\camtracker_3_9_1_0\\SunIntensity.dat", 26 | "motor_offset_threshold": 10, 27 | "restart_if_logs_are_too_old": false, 28 | "restart_if_cover_remains_closed": false 29 | }, 30 | "error_email": { 31 | "smtp_host": "smtp.gmail.com", 32 | "smtp_port": 587, 33 | "smtp_username": "technical-user@domain.com", 34 | "smtp_password": "...", 35 | "sender_address": "technical-user@domain.com", 36 | "notify_recipients": true, 37 | "recipients": "your@mail.com" 38 | }, 39 | "measurement_decision": { 40 | "mode": "automatic", 41 | "manual_decision_result": false, 42 | "cli_decision_result": false 43 | }, 44 | "measurement_triggers": { 45 | "consider_time": true, 46 | "consider_sun_elevation": true, 47 | "consider_helios": false, 48 | "start_time": { 49 | "hour": 7, 50 | "minute": 0, 51 | "second": 0 52 | }, 53 | "stop_time": { 54 | "hour": 21, 55 | "minute": 0, 56 | "second": 0 57 | }, 58 | "min_sun_elevation": 0, 59 | "shutdown_grace_period": 300 60 | }, 61 | "tum_enclosure": null, 62 | "helios": null, 63 | "upload": null 64 | } 65 | -------------------------------------------------------------------------------- /config/helios.config.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "camera_id": 0, 3 | "evaluation_size": 15, 4 | "seconds_per_interval": 6, 5 | "min_seconds_between_state_changes": 180, 6 | "edge_pixel_threshold": 1, 7 | "edge_color_threshold": 40, 8 | "target_pixel_brightness": 50, 9 | "save_images_to_archive": false, 10 | "save_current_image": false 11 | } 12 | -------------------------------------------------------------------------------- /config/tum_enclosure.config.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "ip": "10.0.0.4", 3 | "version": 1, 4 | "controlled_by_user": false 5 | } 6 | -------------------------------------------------------------------------------- /config/upload.config.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "1.2.3.4", 3 | "user": "...", 4 | "password": "...", 5 | "is_active": true, 6 | "only_upload_at_night": true, 7 | "only_upload_when_not_measuring": true, 8 | "streams": [ 9 | { 10 | "is_active": false, 11 | "label": "interferograms", 12 | "variant": "directories", 13 | "dated_regex": "^%Y%m%d$", 14 | "src_directory": "...", 15 | "dst_directory": "...", 16 | "remove_src_after_upload": false 17 | }, 18 | { 19 | "is_active": false, 20 | "label": "datalogger", 21 | "variant": "files", 22 | "dated_regex": "^datalogger-%Y-%m-%d*$", 23 | "src_directory": "...", 24 | "dst_directory": "...", 25 | "remove_src_after_upload": false 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/logs/.gitkeep -------------------------------------------------------------------------------- /logs/activity/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/logs/activity/.gitkeep -------------------------------------------------------------------------------- /logs/archive/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/logs/archive/.gitkeep -------------------------------------------------------------------------------- /logs/helios-autoexposure/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/logs/helios-autoexposure/.gitkeep -------------------------------------------------------------------------------- /logs/helios/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/logs/helios/.gitkeep -------------------------------------------------------------------------------- /logs/tum-enclosure/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/logs/tum-enclosure/.gitkeep -------------------------------------------------------------------------------- /packages/__init__.py: -------------------------------------------------------------------------------- 1 | from . import cli as cli 2 | from . import core as core 3 | -------------------------------------------------------------------------------- /packages/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from . import main as main 2 | -------------------------------------------------------------------------------- /packages/cli/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import config_command_group as config_command_group 2 | from .core import core_command_group as core_command_group 3 | from .logs import logs_command_group as logs_command_group 4 | from .remove_filelocks import remove_filelocks as remove_filelocks 5 | from .state import state_command_group as state_command_group 6 | from .test import test_command_group as test_command_group 7 | from .tum_enclosure import tum_enclosure_command_group as tum_enclosure_command_group 8 | -------------------------------------------------------------------------------- /packages/cli/commands/remove_filelocks.py: -------------------------------------------------------------------------------- 1 | """Remove all filelocks. Helpful when any of the programs crashed during 2 | writing to a file. Normally, this should not be necessary.""" 3 | 4 | import os 5 | 6 | import click 7 | 8 | from packages.core import interfaces, utils 9 | 10 | _dir = os.path.dirname 11 | _PROJECT_DIR = _dir(_dir(_dir(_dir(os.path.abspath(__file__))))) 12 | logger = utils.Logger(origin="cli") 13 | 14 | 15 | def _print_green(text: str) -> None: 16 | click.echo(click.style(text, fg="green")) 17 | 18 | 19 | @click.command( 20 | name="remove-filelocks", 21 | help="Remove all filelocks. Helpful when any of the programs crashed during writing to a file. Normally, this should not be necessary.", 22 | ) 23 | def remove_filelocks() -> None: 24 | with interfaces.StateInterface.update_state() as s: 25 | s.activity.cli_calls += 1 26 | logger.debug('running command "remove-filelocks"') 27 | lock_files = [ 28 | os.path.join(_PROJECT_DIR, "config", ".config.lock"), 29 | os.path.join(_PROJECT_DIR, "config", ".state.lock"), 30 | os.path.join(_PROJECT_DIR, "logs", ".logs.lock"), 31 | ] 32 | for f in lock_files: 33 | if os.path.isfile(f): 34 | os.remove(f) 35 | _print_green(f"Removing {f}") 36 | _print_green("Done!") 37 | -------------------------------------------------------------------------------- /packages/cli/commands/state.py: -------------------------------------------------------------------------------- 1 | """Read the current state.json file.""" 2 | 3 | import click 4 | 5 | from packages.core import interfaces, utils 6 | 7 | logger = utils.Logger(origin="cli") 8 | 9 | 10 | @click.group() 11 | def state_command_group() -> None: 12 | pass 13 | 14 | 15 | @state_command_group.command(name="get", help="Read the current state.json file.") 16 | @click.option("--indent", is_flag=True, help="Print the JSON in an indented manner") 17 | def _get_state(indent: bool) -> None: 18 | logger.debug('running command "state get"') 19 | state = interfaces.StateInterface.load_state() 20 | click.echo(state.model_dump_json(indent=(2 if indent else None))) 21 | -------------------------------------------------------------------------------- /packages/cli/commands/test.py: -------------------------------------------------------------------------------- 1 | import circadian_scp_upload 2 | import click 3 | import fabric.runners 4 | 5 | from packages.core import interfaces, threads, types, utils 6 | 7 | logger = utils.Logger(origin="cli") 8 | 9 | 10 | def _print_green(text: str) -> None: 11 | click.echo(click.style(text, fg="green")) 12 | 13 | 14 | def _print_red(text: str) -> None: 15 | click.echo(click.style(text, fg="red")) 16 | 17 | 18 | @click.group() 19 | def test_command_group() -> None: 20 | pass 21 | 22 | 23 | @test_command_group.command(name="opus") 24 | def _test_opus() -> None: 25 | """Start OPUS, run a macro, stop the macro, close opus.""" 26 | with interfaces.StateInterface.update_state() as s: 27 | s.activity.cli_calls += 1 28 | logger.info('running command "test opus"') 29 | config = types.Config.load() 30 | try: 31 | threads.OpusThread.test_setup(config, logger) 32 | finally: 33 | threads.opus_thread.OpusProgram.stop(logger) 34 | _print_green("Successfully tested opus connection.") 35 | 36 | 37 | @test_command_group.command(name="camtracker") 38 | def _test_camtracker() -> None: 39 | """Start CamTracker, check if it is running, stop CamTracker.""" 40 | with interfaces.StateInterface.update_state() as s: 41 | s.activity.cli_calls += 1 42 | logger.info('running command "test camtracker"') 43 | config = types.Config.load() 44 | threads.camtracker_thread.CamTrackerThread.test_setup(config, logger) 45 | _print_green("Successfully tested CamTracker connection.") 46 | 47 | 48 | @test_command_group.command(name="email") 49 | def _test_emailing() -> None: 50 | """Send a test email.""" 51 | with interfaces.StateInterface.update_state() as s: 52 | s.activity.cli_calls += 1 53 | logger.info('running command "test email"') 54 | config = types.Config.load() 55 | utils.ExceptionEmailClient.send_test_email(config) 56 | _print_green("Successfully sent test email.") 57 | 58 | 59 | @test_command_group.command(name="upload") 60 | def _test_uploading() -> None: 61 | """try to connect to upload server.""" 62 | with interfaces.StateInterface.update_state() as s: 63 | s.activity.cli_calls += 1 64 | logger.info('running command "test upload"') 65 | config = types.Config.load() 66 | if config.upload is None: 67 | _print_red("No upload server configured.") 68 | return 69 | 70 | with circadian_scp_upload.RemoteConnection( 71 | config.upload.host.root, 72 | config.upload.user, 73 | config.upload.password, 74 | ) as remote_connection: 75 | if remote_connection.connection.is_connected: 76 | _print_green("Successfully connected to upload server") 77 | else: 78 | _print_red("Could not connect to upload server") 79 | exit(1) 80 | 81 | result: fabric.runners.Result 82 | 83 | try: 84 | result = remote_connection.connection.run("ls ~ > /dev/null 2>&1") 85 | if result.return_code == 0: 86 | _print_green("Found home directory of upload user account") 87 | else: 88 | raise 89 | except Exception as e: 90 | logger.debug(f"Exception: {e}") 91 | _print_red( 92 | "Upload user account does not have a home directory, " + 'command "ls ~" failed' 93 | ) 94 | exit(1) 95 | 96 | try: 97 | result = remote_connection.connection.run("python3.10 --version > /dev/null 2>&1") 98 | if result.return_code == 0: 99 | _print_green("Found Python3.10 installation on upload server") 100 | else: 101 | raise 102 | except Exception as e: 103 | logger.debug(f"Exception: {e}") 104 | _print_red( 105 | "Python3.10 is not installed on upload server, " 106 | + 'command "python3.10 --version" failed' 107 | ) 108 | exit(1) 109 | -------------------------------------------------------------------------------- /packages/cli/main.py: -------------------------------------------------------------------------------- 1 | """Pyra CLI entry point. Use `pyra-cli --help`/`pyra-cli $command --help` 2 | to see all available commands.`""" 3 | 4 | import sys 5 | 6 | import click 7 | import tum_esm_utils 8 | 9 | _PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=3) 10 | sys.path.append(_PROJECT_DIR) 11 | 12 | from packages.cli.commands import ( 13 | config_command_group, 14 | core_command_group, 15 | logs_command_group, 16 | remove_filelocks, 17 | state_command_group, 18 | test_command_group, 19 | tum_enclosure_command_group, 20 | ) 21 | from packages.core import utils 22 | 23 | logger = utils.Logger(origin="cli") 24 | 25 | 26 | @click.command(help="Print Pyra version and code directory path.") 27 | def print_cli_information() -> None: 28 | logger.debug('running command "info"') 29 | click.echo( 30 | click.style( 31 | f'This CLI is running Pyra version 4.2.2 in directory "{_PROJECT_DIR}"', 32 | fg="green", 33 | ) 34 | ) 35 | 36 | 37 | @click.group() 38 | def cli() -> None: 39 | pass 40 | 41 | 42 | cli.add_command(print_cli_information, name="info") 43 | cli.add_command(config_command_group, name="config") 44 | cli.add_command(core_command_group, name="core") 45 | cli.add_command(logs_command_group, name="logs") 46 | cli.add_command(tum_enclosure_command_group, name="tum-enclosure") 47 | cli.add_command(remove_filelocks, name="remove-filelocks") 48 | cli.add_command(state_command_group, name="state") 49 | cli.add_command(test_command_group, name="test") 50 | 51 | if __name__ == "__main__": 52 | cli.main(prog_name="pyra-cli") 53 | -------------------------------------------------------------------------------- /packages/core/__init__.py: -------------------------------------------------------------------------------- 1 | from . import interfaces as interfaces 2 | from . import main as main 3 | from . import threads as threads 4 | from . import types as types 5 | from . import utils as utils 6 | -------------------------------------------------------------------------------- /packages/core/interfaces/__init__.py: -------------------------------------------------------------------------------- 1 | from .activity_history import ActivityHistoryInterface as ActivityHistoryInterface 2 | from .em27_interface import EM27Interface as EM27Interface 3 | from .state_interface import StateInterface as StateInterface 4 | from .tum_enclosure_interface import TUMEnclosureInterface as TUMEnclosureInterface 5 | -------------------------------------------------------------------------------- /packages/core/interfaces/activity_history.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import datetime 3 | import os 4 | import time 5 | from typing import Optional 6 | import tum_esm_utils 7 | from packages.core import types, utils 8 | 9 | _PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=4) 10 | _MINUTES_BETWEEN_DUMPS = 2 11 | 12 | 13 | class ActivityHistoryInterface: 14 | def __init__(self, logger: utils.Logger) -> None: 15 | self.last_dump_time = time.time() 16 | self.logger = logger 17 | self.activity_history: Optional[types.ActivityHistory] = None 18 | 19 | def get(self) -> tuple[types.ActivityHistory, int]: 20 | """Return the current activity history and the current minute index. 21 | 22 | Creates a new file if it doesn't exist yet.""" 23 | 24 | now = datetime.datetime.now() 25 | minute_index = now.hour * 60 + now.minute 26 | 27 | # if loaded, return if same date otherwise dump and unload 28 | if self.activity_history is not None: 29 | if now.date() == self.activity_history.date: 30 | return self.activity_history, minute_index 31 | else: 32 | ActivityHistoryInterface._dump(self.activity_history) 33 | self.activity_history = None 34 | 35 | # -> a new file has to be loaded/initialized 36 | 37 | path = ActivityHistoryInterface._filepath(now.date()) 38 | if os.path.exists(path): 39 | try: 40 | return types.ActivityHistory.model_validate_json( 41 | tum_esm_utils.files.load_file(path) 42 | ), minute_index 43 | except Exception as e: 44 | self.logger.warning(f"Could not load existing activity history file at {path}: {e}") 45 | 46 | self.logger.info(f"Creating new activity history file at {path}") 47 | new_ah = types.ActivityHistory(date=now.date()) 48 | ActivityHistoryInterface._dump(new_ah) 49 | self.activity_history = new_ah 50 | return new_ah, minute_index 51 | 52 | def update(self, ah: types.ActivityHistory) -> None: 53 | """Update the activity history and dump it to the file system.""" 54 | self.activity_history = ah 55 | if (time.time() - self.last_dump_time) >= (_MINUTES_BETWEEN_DUMPS * 60): 56 | ActivityHistoryInterface._dump(ah) 57 | 58 | def flush(self) -> None: 59 | """Dump the current activity history to the file system.""" 60 | 61 | ah = self.activity_history 62 | if ah is not None: 63 | tum_esm_utils.files.dump_file( 64 | ActivityHistoryInterface._filepath(ah.date), ah.model_dump_json() 65 | ) 66 | 67 | @staticmethod 68 | def _filepath(date: datetime.date) -> str: 69 | return os.path.join( 70 | _PROJECT_DIR, "logs", "activity", f"activity-{date.strftime('%Y-%m-%d')}.json" 71 | ) 72 | 73 | @staticmethod 74 | def _dump(ah: types.ActivityHistory) -> None: 75 | tum_esm_utils.files.dump_file( 76 | ActivityHistoryInterface._filepath(ah.date), ah.model_dump_json() 77 | ) 78 | -------------------------------------------------------------------------------- /packages/core/interfaces/em27_interface.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | from typing import Optional 4 | import tum_esm_utils 5 | import requests 6 | from packages.core import utils 7 | 8 | 9 | class EM27Interface: 10 | """Communicate with the EM27 directly via the HTML interface""" 11 | 12 | @staticmethod 13 | def get_peak_position(ip: tum_esm_utils.validators.StrictIPv4Adress) -> Optional[int]: 14 | """Get the peak position of the EM27. 15 | 16 | This reads the ABP value from the EM27 via http://{ip}/config/servmenuA.htm""" 17 | body = EM27Interface._get_html(ip, "/config/servmenuA.htm") 18 | if body is None: 19 | return None 20 | r: list[str] = re.findall(r' None: 30 | """Set the peak position of the EM27. 31 | 32 | It is equivalent to setting the ABP via http://{ip}/config/servmenuA.htm""" 33 | try: 34 | requests.get( 35 | f"http://{ip.root}/config/servmenuA.htm?sub=Send^&ABP={new_peak_position}^", 36 | timeout=3, 37 | ) 38 | except Exception as e: 39 | raise RuntimeError(f"Could not set peak position") from e 40 | 41 | @staticmethod 42 | def get_last_powerup_timestamp( 43 | ip: tum_esm_utils.validators.StrictIPv4Adress, 44 | ) -> Optional[float]: 45 | """Get the peak position of the EM27. 46 | 47 | This reads the ABP value from the EM27 via http://{ip}/config/cfg_ctrler.htm""" 48 | body = EM27Interface._get_html(ip, "/config/cfg_ctrler.htm") 49 | if body is None: 50 | return None 51 | r: list[str] = re.findall(r"([^<]+)", body) 52 | if len(r) != 1: 53 | return None 54 | 55 | dt = utils.parse_verbal_timedelta_string(r[0]) 56 | last_powerup_time = datetime.datetime.now() - dt 57 | return last_powerup_time.timestamp() 58 | 59 | @staticmethod 60 | def _get_html( 61 | ip: tum_esm_utils.validators.StrictIPv4Adress, 62 | url: str, 63 | ) -> Optional[str]: 64 | """Fetches a HTML page from the EM27: http://{ip}{url}""" 65 | try: 66 | raw_body = requests.get(f"http://{ip.root}{url}", timeout=3) 67 | except Exception: 68 | return None 69 | body = raw_body.text.replace("\n", " ").replace("\t", " ").lower() 70 | while " " in body: 71 | body = body.replace(" ", " ") 72 | return body 73 | -------------------------------------------------------------------------------- /packages/core/interfaces/state_interface.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import datetime 5 | import os 6 | from typing import Generator 7 | 8 | import pydantic 9 | import tum_esm_utils 10 | 11 | from packages.core import types, utils 12 | 13 | _dir = os.path.dirname 14 | _PROJECT_DIR = _dir(_dir(_dir(_dir(os.path.abspath(__file__))))) 15 | _STATE_LOCK_PATH = os.path.join(_PROJECT_DIR, "logs", ".state.lock") 16 | _STATE_FILE_PATH = os.path.join(_PROJECT_DIR, "logs", "state.json") 17 | 18 | logger = utils.Logger(origin="state") 19 | 20 | 21 | class StateInterface: 22 | @staticmethod 23 | @tum_esm_utils.decorators.with_filelock(lockfile_path=_STATE_LOCK_PATH, timeout=5) 24 | def load_state() -> types.StateObject: 25 | """Load the state from the state file.""" 26 | 27 | return StateInterface._load_state_without_filelock() 28 | 29 | @staticmethod 30 | def _load_state_without_filelock() -> types.StateObject: 31 | """Load the state from the state file.""" 32 | 33 | try: 34 | with open(_STATE_FILE_PATH, "r") as f: 35 | state = types.StateObject.model_validate_json(f.read()) 36 | except ( 37 | FileNotFoundError, 38 | pydantic.ValidationError, 39 | UnicodeDecodeError, 40 | ) as e: 41 | logger.warning(f"Could not load state file - Creating new one: {e}") 42 | state = types.StateObject(last_updated=datetime.datetime.now()) 43 | with open(_STATE_FILE_PATH, "w") as f: 44 | f.write(state.model_dump_json(indent=4)) 45 | return state 46 | 47 | @staticmethod 48 | @contextlib.contextmanager 49 | @tum_esm_utils.decorators.with_filelock(lockfile_path=_STATE_LOCK_PATH, timeout=5) 50 | def update_state() -> Generator[types.StateObject, None, None]: 51 | """Update the state file in a context manager. 52 | 53 | Example: 54 | 55 | ```python 56 | with interfaces.StateInterface.update_state() as state: 57 | state.helios_indicates_good_conditions = "yes" 58 | ``` 59 | 60 | The file will be locked correctly, so that no other process can 61 | interfere with the state file and the state 62 | """ 63 | 64 | state = StateInterface._load_state_without_filelock() 65 | state_before = state.model_copy(deep=True) 66 | 67 | yield state 68 | 69 | if state != state_before: 70 | state.last_updated = datetime.datetime.now() 71 | with open(_STATE_FILE_PATH, "w") as f: 72 | f.write(state.model_dump_json(indent=4)) 73 | -------------------------------------------------------------------------------- /packages/core/threads/__init__.py: -------------------------------------------------------------------------------- 1 | from . import abstract_thread as abstract_thread 2 | from .camtracker_thread import CamTrackerThread as CamTrackerThread 3 | from .cas_thread import CASThread as CASThread 4 | from .helios_thread import HeliosThread as HeliosThread 5 | from .opus_thread import OpusThread as OpusThread 6 | from .system_monitor_thread import SystemMonitorThread as SystemMonitorThread 7 | from .tum_enclosure_thread import TUMEnclosureThread as TUMEnclosureThread 8 | from .upload_thread import UploadThread as UploadThread 9 | -------------------------------------------------------------------------------- /packages/core/threads/abstract_thread.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import threading 3 | import time 4 | from typing import Optional 5 | 6 | from packages.core import types, utils 7 | 8 | 9 | class AbstractThread(abc.ABC): 10 | """Abstract base class for all threads""" 11 | 12 | logger_origin: Optional[str] = None 13 | 14 | def __init__(self) -> None: 15 | """Initialize the thread instance. This does not start the 16 | thread but only initializes the instance that triggers the 17 | thread to start and stop correctly.""" 18 | 19 | assert self.__class__.logger_origin is not None 20 | self.logger: utils.Logger = utils.Logger(origin=self.__class__.logger_origin) 21 | self.thread = self.get_new_thread_object() 22 | self.thread_start_time: Optional[float] = None 23 | 24 | def update_thread_state(self, config: types.Config) -> bool: 25 | """Use `self.should_be_running` to determine if the thread 26 | should be running or not. If it should be running and it is 27 | not running, start the thread. If it should not be running 28 | and it is running, stop the thread. 29 | 30 | Returns True if the thread is running/pausing correctly, False 31 | otherwise.""" 32 | 33 | should_be_running: bool = self.__class__.should_be_running(config) 34 | 35 | if should_be_running: 36 | if self.thread_start_time is not None: 37 | if self.thread.is_alive(): 38 | self.logger.debug("Thread is running correctly") 39 | return True 40 | else: 41 | now = time.time() 42 | if (self.thread_start_time - now) <= 43199: 43 | self.logger.debug("Thread has crashed/stopped, running teardown") 44 | self.thread.join() 45 | self.thread_start_time = None 46 | # set up a new thread instance for the next time the thread should start 47 | self.thread = self.get_new_thread_object() 48 | else: 49 | self.logger.debug("Starting the thread") 50 | self.thread.start() 51 | self.thread_start_time = time.time() 52 | 53 | else: 54 | if self.thread_start_time is not None: 55 | self.logger.debug("Joining the thread") 56 | self.thread.join() 57 | self.thread = self.get_new_thread_object() 58 | self.thread_start_time = None 59 | else: 60 | self.logger.debug("Thread is pausing") 61 | return True 62 | 63 | return False 64 | 65 | @staticmethod 66 | @abc.abstractmethod 67 | def should_be_running(config: types.Config) -> bool: 68 | """Based on the config, should the thread be running or not?""" 69 | 70 | @staticmethod 71 | @abc.abstractmethod 72 | def get_new_thread_object() -> threading.Thread: 73 | """Return a new thread object that is to be started.""" 74 | 75 | @staticmethod 76 | @abc.abstractmethod 77 | def main(headless: bool = False) -> None: 78 | """Main entrypoint of the thread. In headless mode, 79 | don't write to log files but print to console.""" 80 | -------------------------------------------------------------------------------- /packages/core/types/__init__.py: -------------------------------------------------------------------------------- 1 | from .activity_history import ActivityHistory as ActivityHistory 2 | from .enclosures import tum_enclosure as tum_enclosure 3 | from .plc_specification import PLCSpecification as PLCSpecification 4 | from .plc_specification import PLCSpecificationActors as PLCSpecificationActors 5 | from .plc_specification import PLCSpecificationConnections as PLCSpecificationConnections 6 | from .plc_specification import PLCSpecificationControl as PLCSpecificationControl 7 | from .plc_specification import PLCSpecificationPower as PLCSpecificationPower 8 | from .plc_specification import PLCSpecificationSensors as PLCSpecificationSensors 9 | from .plc_specification import PLCSpecificationState as PLCSpecificationState 10 | 11 | from .config import Config as Config 12 | from .config import PartialConfig as PartialConfig 13 | 14 | from .state import ExceptionStateItem as ExceptionStateItem 15 | from .state import OperatingSystemState as OperatingSystemState 16 | from .state import Position as Position 17 | from .state import StateObject as StateObject 18 | -------------------------------------------------------------------------------- /packages/core/types/activity_history.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pydantic 3 | 4 | 5 | class ActivityHistory(pydantic.BaseModel): 6 | date: datetime.date 7 | is_running: list[int] = [0] * 24 * 60 8 | is_measuring: list[int] = [0] * 24 * 60 9 | has_errors: list[int] = [0] * 24 * 60 10 | camtracker_startups: list[int] = [0] * 24 * 60 11 | opus_startups: list[int] = [0] * 24 * 60 12 | cli_calls: list[int] = [0] * 24 * 60 13 | is_uploading: list[int] = [0] * 24 * 60 14 | -------------------------------------------------------------------------------- /packages/core/types/enclosures/__init__.py: -------------------------------------------------------------------------------- 1 | from . import tum_enclosure as tum_enclosure 2 | -------------------------------------------------------------------------------- /packages/core/types/enclosures/tum_enclosure.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Literal, Optional 3 | 4 | from tum_esm_utils.validators import StricterBaseModel, StrictIPv4Adress 5 | 6 | # --- CONFIG --- 7 | 8 | 9 | class TUMEnclosureConfig(StricterBaseModel): 10 | ip: StrictIPv4Adress 11 | version: Literal[1, 2] 12 | controlled_by_user: bool 13 | 14 | 15 | class PartialTUMEnclosureConfig(StricterBaseModel): 16 | """Like `TUMEnclosureConfig`, but all fields are optional.""" 17 | 18 | ip: Optional[StrictIPv4Adress] = None 19 | version: Optional[Literal[1, 2]] = None 20 | controlled_by_user: Optional[bool] = None 21 | 22 | 23 | # --- STATE --- 24 | 25 | 26 | class ActorsState(StricterBaseModel): 27 | fan_speed: Optional[int] = None 28 | current_angle: Optional[int] = None 29 | 30 | 31 | class ControlState(StricterBaseModel): 32 | auto_temp_mode: Optional[bool] = None 33 | manual_control: Optional[bool] = None 34 | manual_temp_mode: Optional[bool] = None 35 | sync_to_tracker: Optional[bool] = None 36 | 37 | 38 | class SensorsState(StricterBaseModel): 39 | humidity: Optional[int] = None 40 | temperature: Optional[int] = None 41 | 42 | 43 | class StateState(StricterBaseModel): 44 | cover_closed: Optional[bool] = None 45 | motor_failed: Optional[bool] = None 46 | rain: Optional[bool] = None 47 | reset_needed: Optional[bool] = None 48 | ups_alert: Optional[bool] = None 49 | 50 | 51 | class PowerState(StricterBaseModel): 52 | camera: Optional[bool] = None 53 | computer: Optional[bool] = None 54 | heater: Optional[bool] = None 55 | router: Optional[bool] = None 56 | spectrometer: Optional[bool] = None 57 | 58 | 59 | class ConnectionsState(StricterBaseModel): 60 | camera: Optional[bool] = None 61 | computer: Optional[bool] = None 62 | heater: Optional[bool] = None 63 | router: Optional[bool] = None 64 | spectrometer: Optional[bool] = None 65 | 66 | 67 | class TUMEnclosureState(StricterBaseModel): 68 | last_full_fetch: Optional[datetime.datetime] = None 69 | actors: ActorsState = ActorsState() 70 | control: ControlState = ControlState() 71 | sensors: SensorsState = SensorsState() 72 | state: StateState = StateState() 73 | power: PowerState = PowerState() 74 | connections: ConnectionsState = ConnectionsState() 75 | -------------------------------------------------------------------------------- /packages/core/types/plc_specification.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import pydantic 4 | 5 | 6 | class PLCSpecificationActors(pydantic.BaseModel): 7 | current_angle: tuple[int, int, int] 8 | fan_speed: tuple[int, int, int] 9 | move_cover: tuple[int, int, int] 10 | nominal_angle: tuple[int, int, int] 11 | 12 | 13 | class PLCSpecificationControl(pydantic.BaseModel): 14 | auto_temp_mode: tuple[int, int, int, int] 15 | manual_control: tuple[int, int, int, int] 16 | manual_temp_mode: tuple[int, int, int, int] 17 | reset: tuple[int, int, int, int] 18 | sync_to_tracker: tuple[int, int, int, int] 19 | 20 | 21 | class PLCSpecificationSensors(pydantic.BaseModel): 22 | humidity: tuple[int, int, int] 23 | temperature: tuple[int, int, int] 24 | 25 | 26 | class PLCSpecificationState(pydantic.BaseModel): 27 | cover_closed: tuple[int, int, int, int] 28 | motor_failed: Optional[tuple[int, int, int, int]] 29 | rain: tuple[int, int, int, int] 30 | reset_needed: tuple[int, int, int, int] 31 | ups_alert: tuple[int, int, int, int] 32 | 33 | 34 | class PLCSpecificationPower(pydantic.BaseModel): 35 | camera: tuple[int, int, int, int] 36 | computer: Optional[tuple[int, int, int, int]] 37 | heater: tuple[int, int, int, int] 38 | router: Optional[tuple[int, int, int, int]] 39 | spectrometer: tuple[int, int, int, int] 40 | 41 | 42 | class PLCSpecificationConnections(pydantic.BaseModel): 43 | camera: Optional[tuple[int, int, int, int]] 44 | computer: tuple[int, int, int, int] 45 | heater: tuple[int, int, int, int] 46 | router: tuple[int, int, int, int] 47 | spectrometer: Optional[tuple[int, int, int, int]] 48 | 49 | 50 | class PLCSpecification(pydantic.BaseModel): 51 | actors: PLCSpecificationActors 52 | control: PLCSpecificationControl 53 | sensors: PLCSpecificationSensors 54 | state: PLCSpecificationState 55 | power: PLCSpecificationPower 56 | connections: PLCSpecificationConnections 57 | -------------------------------------------------------------------------------- /packages/core/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .astronomy import Astronomy as Astronomy 2 | from .exception_email_client import ExceptionEmailClient as ExceptionEmailClient 3 | from .functions import read_last_file_line as read_last_file_line 4 | from .functions import find_most_recent_files as find_most_recent_files 5 | from .functions import parse_verbal_timedelta_string as parse_verbal_timedelta_string 6 | from .helios_image_processing import HeliosImageProcessing as HeliosImageProcessing 7 | from .old_helios_image_processing import OldHeliosImageProcessing as OldHeliosImageProcessing 8 | from .logger import Logger as Logger 9 | from .tum_enclosure_logger import TUMEnclosureLogger as TUMEnclosureLogger 10 | -------------------------------------------------------------------------------- /packages/core/utils/astronomy.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | from typing import Any, Optional 4 | 5 | import skyfield.api 6 | import tum_esm_utils 7 | 8 | from packages.core import types 9 | 10 | PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=4) 11 | 12 | 13 | class Astronomy: 14 | _PLANETS: Any = None 15 | 16 | @staticmethod 17 | def load_astronomical_dataset() -> None: 18 | """Loads the astronomical dataset DE421 from the NASA JPL website, 19 | see https://ssd.jpl.nasa.gov/planets/eph_export.html.""" 20 | 21 | filepath = os.path.join(PROJECT_DIR, "config", "astronomy_dataset_de421.bsp") 22 | assert os.path.isfile(filepath), "Astronomical dataset not found" 23 | 24 | if Astronomy._PLANETS is None: 25 | Astronomy._PLANETS = skyfield.api.load_file(filepath) 26 | 27 | @staticmethod 28 | def get_current_sun_elevation( 29 | config: types.Config, 30 | lat: Optional[float] = None, 31 | lon: Optional[float] = None, 32 | alt: Optional[float] = None, 33 | datetime_object: Optional[datetime.datetime] = None, 34 | ) -> float: 35 | """Computes current sun elevation in degree, based on the 36 | coordinates from the CamTracker config file.""" 37 | 38 | assert Astronomy._PLANETS is not None, "Astronomical dataset not loaded" 39 | earth = Astronomy._PLANETS["Earth"] 40 | sun = Astronomy._PLANETS["Sun"] 41 | 42 | if datetime_object is not None: 43 | current_timestamp = datetime_object.timestamp() # type: ignore 44 | assert isinstance(current_timestamp, float) 45 | current_time = skyfield.api.load.timescale().from_datetime( 46 | datetime.datetime.fromtimestamp(current_timestamp, tz=skyfield.api.utc) 47 | ) 48 | else: 49 | current_time = skyfield.api.load.timescale().now() 50 | 51 | if (lat is None) or (lon is None) or (alt is None): 52 | lat, lon, alt = Astronomy.get_camtracker_coordinates(config) 53 | 54 | current_position = earth + skyfield.api.wgs84.latlon( 55 | latitude_degrees=lat, 56 | longitude_degrees=lon, 57 | elevation_m=alt, 58 | ) 59 | 60 | sun_pos = current_position.at(current_time).observe(sun).apparent() 61 | altitude, _, _ = sun_pos.altaz() 62 | return round(float(altitude.degrees), 3) 63 | 64 | @staticmethod 65 | def get_camtracker_coordinates(config: types.Config) -> tuple[float, float, float]: 66 | """Returns the coordinates from the CamTracker config file as (lat, lon, alt).""" 67 | 68 | if config.general.test_mode: 69 | return (48.151, 11.569, 539) # TUM_I location in munich 70 | 71 | try: 72 | with open(config.camtracker.config_path.root, "r") as f: 73 | _lines = f.readlines() 74 | _marker_line_index: Optional[int] = None 75 | for n, line in enumerate(_lines): 76 | if line == "$1\n": 77 | _marker_line_index = n 78 | assert _marker_line_index is not None, "Could not find $1 marker" 79 | lat = float(_lines[_marker_line_index + 1].strip()) 80 | lon = float(_lines[_marker_line_index + 2].strip()) 81 | alt = float(_lines[_marker_line_index + 3].strip()) * 1000 82 | return lat, lon, alt 83 | except Exception as e: 84 | raise Exception( 85 | "Could not read CamTracker config file. Please make sure that " 86 | f"the config located at {config.camtracker.config_path.root} is " 87 | "valid or change the path in the config." 88 | ) from e 89 | -------------------------------------------------------------------------------- /packages/core/utils/functions.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import glob 3 | import os 4 | import time 5 | from typing import Literal 6 | 7 | 8 | def read_last_file_line( 9 | file_path: str, 10 | ignore_trailing_whitespace: bool = True, 11 | ) -> str: 12 | """Reads the last non empty line of a file""" 13 | 14 | with open(file_path, "rb") as f: 15 | f.seek(-1, os.SEEK_END) 16 | 17 | if ignore_trailing_whitespace: 18 | while f.read(1) in [b"\n", b" "]: 19 | try: 20 | f.seek(-2, os.SEEK_CUR) 21 | except OSError: 22 | # reached the beginning of the file 23 | return "" 24 | 25 | f.seek(-1, os.SEEK_CUR) 26 | # now the cursor is right before the last 27 | # character that is not a newline or a space 28 | 29 | last_line: bytes = b"" 30 | new_character: bytes = b"" 31 | while True: 32 | new_character = f.read(1) 33 | if new_character == b"\n": 34 | break 35 | last_line += new_character 36 | f.seek(-2, os.SEEK_CUR) 37 | 38 | return last_line.decode().strip()[::-1] 39 | 40 | 41 | def find_most_recent_files( 42 | directory_path: str, 43 | time_limit: float, 44 | time_indicator: Literal["created", "modified"], 45 | ) -> list[str]: 46 | """Find the most recently modified files in a directory. 47 | 48 | Args: 49 | directory_path: The path to the directory to search. 50 | time_limit: The time limit in seconds. 51 | 52 | Returns: 53 | A list of the most recently modified absolute filepaths sorted by 54 | modification time (the most recent first) and only including files 55 | modified within the time limit. 56 | """ 57 | if time_limit <= 0: 58 | return [] 59 | 60 | current_timestamp = time.time() 61 | files = [f for f in glob.glob(os.path.join(directory_path, "*")) if os.path.isfile(f)] 62 | modification_times = [os.path.getmtime(f) for f in files] 63 | creation_times = [os.path.getctime(f) for f in files] 64 | times = modification_times if time_indicator == "modified" else creation_times 65 | merged = sorted( 66 | [(f, t) for f, t in list(zip(files, times)) if t >= (current_timestamp - time_limit)], 67 | key=lambda x: x[1], 68 | reverse=True, 69 | ) 70 | return [f for f, t in merged] 71 | 72 | 73 | def parse_verbal_timedelta_string(timedelta_string: str) -> datetime.timedelta: 74 | """Parse a timedelta string like "1 year, 2 days, 3 hours, 4 mn" into a timedelta object. 75 | 76 | The string does not have to contain all components. The only requirement is that the 77 | components are separated by ", ".""" 78 | 79 | years = days = hours = minutes = 0 80 | 81 | # Parse each part 82 | for part in timedelta_string.split(", "): 83 | if "year" in part: 84 | years = int(part.split(" ")[0]) 85 | elif "day" in part: 86 | days = int(part.split(" ")[0]) 87 | elif "hour" in part: 88 | hours = int(part.split(" ")[0]) 89 | elif ("mn" in part) or ("min" in part): 90 | minutes = int(part.split(" ")[0]) 91 | 92 | # Convert years to days (approximate, assuming 365 days per year) 93 | days += years * 365 94 | 95 | # Create and return the timedelta object 96 | return datetime.timedelta(days=days, hours=hours, minutes=minutes) 97 | -------------------------------------------------------------------------------- /packages/core/utils/tum_enclosure_logger.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | import tum_esm_utils 5 | 6 | from packages.core import types 7 | 8 | _PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=4) 9 | 10 | 11 | class TUMEnclosureLogger: 12 | """A class to save the current TUM Enclosure state to a log file.""" 13 | 14 | @staticmethod 15 | def log(config: types.Config, state: types.StateObject) -> None: 16 | now = datetime.datetime.now() 17 | timestamp = now.timestamp() 18 | datetime_string = now.isoformat() 19 | 20 | log_line = [ 21 | timestamp, 22 | datetime_string, 23 | state.position.sun_elevation, 24 | state.tum_enclosure_state.power.heater, 25 | state.tum_enclosure_state.power.spectrometer, 26 | state.tum_enclosure_state.state.rain, 27 | state.tum_enclosure_state.actors.fan_speed, 28 | state.tum_enclosure_state.actors.current_angle, 29 | state.tum_enclosure_state.sensors.temperature, 30 | state.tum_enclosure_state.sensors.humidity, 31 | ] 32 | stringed_log_line = [str(item).lower() if item is not None else "null" for item in log_line] 33 | current_log_file = os.path.join( 34 | _PROJECT_DIR, 35 | "logs", 36 | "tum-enclosure", 37 | f"{config.general.station_id}-tum-enclosure-logs-{now.strftime('%Y-%m-%d')}.csv", 38 | ) 39 | if not os.path.isfile(current_log_file): 40 | with open(current_log_file, "w") as f: 41 | f.write( 42 | "timestamp,datetime,sun_elevation,heater_power," 43 | + "spectrometer_power,rain_detected,fan_speed," 44 | + "cover_angle,temperature,humidity\n" 45 | ) 46 | with open(current_log_file, "a") as f: 47 | f.write(",".join(stringed_log_line) + "\n") 48 | -------------------------------------------------------------------------------- /packages/docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /packages/docs/.node-version: -------------------------------------------------------------------------------- 1 | 20.13.1 -------------------------------------------------------------------------------- /packages/docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /packages/docs/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/bun.lockb -------------------------------------------------------------------------------- /packages/docs/docs/contributor-guide/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Contributor Guide", 3 | "position": 4 4 | } 5 | -------------------------------------------------------------------------------- /packages/docs/docs/contributor-guide/becoming-a-contributor.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | sidebar_label: Becoming a Contributor 4 | hide_table_of_contents: true 5 | --- 6 | 7 | # How to Become a Contributor 8 | 9 | 1. Please read the sections [**Repository Organization**](/docs/developer-guide/repository-organization) and [**Testing and CI**](/docs/developer-guide/testing-and-ci) 10 | 2. Look for your feature idea/bug fix on our [**issue tracker**](https://github.com/tum-esm/pyra/issues) 11 | 3. Submit an issue if your contribution idea is not being worked on yet [**using our issue templates**](https://github.com/tum-esm/pyra/issues/new/choose) 12 | 4. [**Reach out to us**](/docs/intro/contact) if you want to discuss your ideas or want to add your solutions 13 | 14 | 15 | 16 | Original by Nedroid 17 | -------------------------------------------------------------------------------- /packages/docs/docs/developer-guide/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Developer Guide", 3 | "position": 3 4 | } 5 | -------------------------------------------------------------------------------- /packages/docs/docs/developer-guide/architecture.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | hide_table_of_contents: true 4 | --- 5 | 6 | # Architecture 7 | 8 | **The hierarchy between the three parts of Pyra:** 9 | 10 | 14 | 15 | **Inside Pyra Core's main loop:** 16 | 17 | - The Core will infinitely loop over the modules `MeasurementConditions`, `EnclosureControl`, `SunTracking`, `OpusMeasurements`, and `SystemChecks` 18 | - The Core will start the Upload- and Helios-Thread when they should be running but are not 19 | - The threads will stop themselves based on the config 20 | 21 | **The communication via the `state.json` file:** 22 | 23 | 27 | 28 |
29 |
30 | 31 | ## Pyra Core Directory Structure 32 | 33 | ### Responsibilities 34 | 35 | - `types` contains all types used in the codebase. The whole codebase has static-type hints. A static-type analysis can be done using MyPy (see `scripts/`). 36 | - `utils` contains all supporting functionality used in one or more places. 37 | - `interfaces` includes the "low-level" code to interact with the PLC, the operating system, and the config- and state files. 38 | - `threads` contains the logic that Pyra Core runs parallel to the main thread. 39 | 40 | ### Import hierarchy 41 | 42 | ```mermaid 43 | graph LR; 44 | A["types"] -- imported by --> B; 45 | B["utils"] -- imported by --> C; 46 | C["interfaces"] -- imported by --> D; 47 | D["threads"] -- imported by --> E["main.py"]; 48 | ``` 49 | 50 | _\* the graph is transient_ 51 | -------------------------------------------------------------------------------- /packages/docs/docs/developer-guide/external-interfaces.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | sidebar_label: External Interfaces 4 | hide_table_of_contents: true 5 | --- 6 | 7 | # Interfaces with external programs 8 | 9 | ## OPUS 10 | 11 | Up to version 4.1.4, PYRA used DDE to communicate with OPUS. Since Microsoft replaced it internally with Windows 95, the interface has only been kept for compatibility reasons. This means that there is almost no documentation or libraries for DDE. Since OPUS also has an HTTP interface, Pyra 4.2.0 and above uses this HTTP interface. We added the OPUS HTTP Client to our Python utility library, so one can use it outside of PYRA to: [see `tum-esm-utils`](https://tum-esm-utils.netlify.app/example-usage#opus-http-interface). 12 | 13 | ## CamTracker 14 | 15 | The `CamTrackerConfig.txt` file contains geographical coordinates Pyra Core uses in its Astronomy utility class. The coordinates can be found by looking for the `$1` mark inside the file: 16 | 17 | ``` 18 | ... 19 | 20 | $1 21 | 48.15 22 | 11.57 23 | 0.54 24 | 25 | ... 26 | ``` 27 | 28 | The `LEARN_Az_Elev.dat` file contains CamTracker's logs about the mirror position and the currently estimated sun position. Example: 29 | 30 | ```dat 31 | Julian Date, Tracker Elevation, Tracker Azimuth, Elev Offset from Astro, Az Offset from Astro, Ellipse distance/px 32 | 2458332.922778,107.490800,149.545000,0.197305,0.188938,705.047211 33 | 2458332.922836,107.494400,149.761400,0.192179,0.365420,736.914133 34 | 2458332.922905,107.498400,150.208200,0.188914,0.778934,736.914133 35 | 2458332.922975,107.499200,149.811600,0.179557,0.335728,736.914133 36 | 2458332.923032,107.508400,149.647800,0.182958,0.145281,736.914133 37 | ... 38 | ``` 39 | 40 | The `SunIntensity.dat` file contains CamTracker's own evaluation of the sun conditions. This is currently not used in Pyra Core, but it might be in the future. Example: 41 | 42 | ```dat 43 | Julian Date, Date UTC[yyyy/MM/dd HH:mm:ss], Intensity[px*ms], big and small obj good 44 | 2457057.199028, 2015/02/03 16:46:36, 0.000000, good 45 | 2457057.200347, 2015/02/03 16:48:30, 0.000000, good 46 | 2458332.906019, 2018/08/02 09:44:40, 0.000000, good 47 | 2458332.906088, 2018/08/02 09:44:46, nan, bad 48 | 2458332.906169, 2018/08/02 09:44:53, nan, bad 49 | ... 50 | ``` 51 | 52 | When stopping CamTracker, we have to add a `stop.txt` inside CamTracker's code directory, and the application will shut down gracefully. 53 | 54 | ## TUM PLC 55 | 56 | We are using the Snap7 Python library to communicate with the TUM PLC. Here is a manual for the underlying Snap7 API: http://snap7.sourceforge.net/sharp7.html 57 | -------------------------------------------------------------------------------- /packages/docs/docs/developer-guide/internal-interfaces.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | sidebar_label: Internal Interfaces 4 | hide_table_of_contents: true 5 | --- 6 | 7 | # Interfaces used internally 8 | 9 | ## `config.json` 10 | 11 | The config file under `config/config.json` contains all the parameters to tweak Pyra's operation. Schema: [/packages/core/types/config.py](https://github.com/tum-esm/pyra/blob/main/packages/core/types/config.py). 12 | 13 | ## `state.json` 14 | 15 | The state file is generated under `logs/state.json`. Pyra Core writes its internal values to this file. The state file is used to communicate between modules as well as with the "outside" world (UI, CLI). Schema: [/packages/core/types/state.py](https://github.com/tum-esm/pyra/blob/main/packages/core/types/state.py). 16 | 17 | ## Validation strategy 18 | 19 | [MyPy](https://github.com/python/mypy) will make full use of the schemas included above (see [testing](/docs/developer-guide/testing-and-ci)). Whenever loading the config- or state files, the respective schema validation will run. Hence, Pyra will detect when a JSON file does not have the expected schema and raise a precise Exception. All internal code interfaces (function calls, etc.) are covered by the strict MyPy validation. 20 | 21 | :::info 22 | 23 | With `pyra-cli config get`, only the schema will be validated, not the value rules. This command is necessary because the UI can deal with invalid values but not with an invalid schema. 24 | 25 | ::: 26 | 27 | ## How the UI reads logs and state 28 | 29 | The UI reads the log files and the state file periodically using [Tauri's file system API](https://tauri.app/v1/api/js/modules/fs). We tested using sockets or file watchers, but both did not work well on Windows and reading it periodically is the most basic implementation. 30 | 31 | ## Logging 32 | 33 | All scripts that output messages at runtime should use the `Logger` class: 34 | 35 | ```python 36 | from packages.core import utils 37 | 38 | logger = utils.Logger() 39 | 40 | logger.debug("...") 41 | logger.info("...") 42 | logger.warning("...") 43 | logger.critical("...") 44 | logger.error("...") 45 | 46 | 47 | # By default, it will log from a "pyra.core" origin 48 | logger = utils.Logger() 49 | 50 | # Here, it will log from a "camtracker" origin 51 | logger = utils.Logger(origin="camtracker") 52 | ``` 53 | 54 | Messages from all log levels can be found in `logs/debug.log`, and messages from levels INFO/WARNING/CRITICAL/ERROR can be found in `logs/info.log`. 55 | 56 | ## Activity Log 57 | 58 | _Pyra Core_ stores its activity (is it measuring, do errors exist, etc.) in `logs/activity/activity-YYYY-MM-DD.json`. This is the same information as in the regular log files but significantly easier to parse. Schema: [/packages/core/types/activity_history.py](https://github.com/tum-esm/pyra/blob/main/packages/core/types/activity_history.py). 59 | 60 | ## Pyra CLI commands from UI 61 | 62 | All write operations from the UI (update config, etc.) are done by running Pyra CLI commands. This is why we have to use the global Python interpreter instead of a virtual environment: We did not make it work that the [shell interface from Tauri](https://tauri.app/v1/api/js/modules/shell) can make use of a virtual environment. 63 | -------------------------------------------------------------------------------- /packages/docs/docs/developer-guide/running-pyra-manually.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | hide_table_of_contents: true 4 | --- 5 | 6 | # Running Pyra manually 7 | 8 | ## Install dependencies 9 | 10 | Dependency management uses [PDM](https://pdm-project.org/latest). `pdm install --dev` installs all dependencies in the currently activated interpreter environment. 11 | 12 | ## Pyra Core 13 | 14 | Normally, Pyra should be started via the CLI: 15 | 16 | ```bash 17 | pyra-cli core start 18 | pyra-cli core is-running 19 | pyra-cli core stop 20 | ``` 21 | 22 | The start command will execute `python run-pyra-core.py` in a background process. 23 | 24 | ## Upload/Helios threads 25 | 26 | In Pyra Core's regular operation, it will start and stop Upload and Helios in dedicated threads. These threads will write their output into the Core's log files. You can run these two threads in headless mode (without Pyra Core), and they will print all of their logs to the console: 27 | 28 | ```bash 29 | python run-headless-helios-thread.py 30 | python run-headless-upload-thread.py 31 | ``` 32 | 33 | ## Building the frontend 34 | 35 | Inside the `packages/ui` directory: 36 | 37 | - `yarn` will install all dependencies 38 | - `yarn tauri dev` will start a development server (with hot reload) 39 | - `yarn tauri build` will bundle the Tauri application and put the installer for your operating system into the `packages/ui/src-tauri/target/release/bundle` directory 40 | 41 | ## Building the documentation 42 | 43 | Inside the `packages/docs` directory: 44 | 45 | - `yarn` will install all dependencies 46 | - `yarn develop` will start a development server (with hot reload) 47 | - `yarn build` will render the static HTML and bundle the SPA into the `packages/docs/build` directory 48 | -------------------------------------------------------------------------------- /packages/docs/docs/developer-guide/testing-and-ci.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | sidebar_label: Testing & CI 4 | hide_table_of_contents: true 5 | --- 6 | 7 | # Testing and Continuous Integration 8 | 9 | ## Testing Strategy 10 | 11 | The complete software only runs when OPUS and CamTracker are installed and an EM27 is connected. However, in test mode, the software can run independently without interacting with any modules requiring a particular setup. Most functionality is tested by running the software and conducting measurements. Since Pyra only starts/stops the measurement process but does not affect output files, these output files from OPUS do not have to be tested. 12 | 13 | ## Testing with Pytest 14 | 15 | Testing is done using [PyTest](https://github.com/pytest-dev/pytest/). All tests can be found in the `tests/` directory. There are two types of test functions: 16 | 17 | ```python 18 | # can be run without config.json/hardware setup 19 | @pytest.mark.ci 20 | def test_something(): 21 | pass 22 | 23 | # require config.json and hardware setup 24 | @pytest.mark.integration 25 | def test_something_else(): 26 | pass 27 | ``` 28 | 29 | Run the two test categories with: 30 | 31 | ```bash 32 | python -m pytest -m "ci" tests 33 | python -m pytest -m "integration" tests 34 | ``` 35 | 36 | The following can be used to test whether **emailing and uploading** is configured correctly: 37 | 38 | ```bash 39 | python -m pytest tests/integration/test_emailing.py 40 | python -m pytest tests/integration/test_upload.py 41 | ``` 42 | 43 | We are using the strict MyPy settings with a few exceptions that can be found in `pyproject.toml`. 44 | 45 | ## Static Typing for Pyra UI 46 | 47 | The whole frontend is written in [Typescript](https://github.com/microsoft/TypeScript). Testing the code's integrity can be done by building the UI: 48 | 49 | ```bash 50 | cd packages/ui 51 | yarn build 52 | ``` 53 | 54 | ## Continuous Integration 55 | 56 | **Test on Main:** Will run all CI tests, MyPy, and build the UI _on every commit on `main` or every PR on `main`_. 57 | 58 | **Build on Prerelease:** Will build the UI and generate a release named "vX.Y.Z-prerelease (generated by CI)" that has the `.msi` file attached to it _on every commit on `prerelease`_. 59 | 60 | **Build on Release:** Will build the UI and generate a release named "vX.Y.Z (generated by CI)" that has the `.msi` file attached to it _on every commit on `release`_. 61 | 62 | :::info 63 | 64 | The `release` branch should only contain code from the latest official release. Use the `prerelease` branch to bundle the installable UI on demand. 65 | 66 | ::: 67 | -------------------------------------------------------------------------------- /packages/docs/docs/intro/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Getting Started", 3 | "position": 1 4 | } 5 | -------------------------------------------------------------------------------- /packages/docs/docs/user-guide/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "User Guide", 3 | "position": 2 4 | } 5 | -------------------------------------------------------------------------------- /packages/docs/docs/user-guide/automatic-peak-positioning.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 6 3 | sidebar_label: Automatic Peak Positioning 4 | hide_table_of_contents: true 5 | --- 6 | 7 | # Automatic Peak Positioning (APP) 8 | 9 | Whenever powering up the spectrometer, the EM27/SUN does not accurately know the pendulum's position. That is why it is recommended to save the center position of recorded interferograms after every powerup so that no points on one of the sides of the interferogram get lost because it is not stored centered. In OPUS, you can do this in "Measure > Setup Measurement Parameters > Check Signal". Or you can let PYRA do this after every powerup by configuring it in the OPUS Configuration and save a few minutes per system per day. 10 | 11 | The Automatic Peak Positioning (APP) feature searches for OPUS files written to the local disk within the last 15 minutes and after the last EM27/SUN powerup. It loads the interferograms from these OPUS files using the [`tum-esm-utils`](https://tum-esm-utils.netlify.app/api-reference#tum_esm_utilsopus) Python library and calculates the peak position. If the peak position from the last five readable OPUS files is identical and is less than 200 points off the center, this new peak position will be sent to the EM27/SUN. 12 | 13 | The logs from the OPUS thread when performing the APP will look something like this: 14 | 15 | ```log 16 | 2025-04-19 04:44:23.020353 UTC+0000 - opus - DEBUG - Starting iteration 17 | 2025-04-19 04:44:23.023355 UTC+0000 - opus - DEBUG - Loading configuration file 18 | 2025-04-19 04:44:23.042385 UTC+0000 - opus - DEBUG - Checking if OPUS is running 19 | 2025-04-19 04:44:23.082358 UTC+0000 - opus - DEBUG - Macro is running as expected 20 | 2025-04-19 04:44:23.116354 UTC+0000 - opus - INFO - Trying to set peak position 21 | 2025-04-19 04:44:23.140355 UTC+0000 - opus - DEBUG - APP: Time since last powerup is 5280.00 seconds 22 | 2025-04-19 04:44:23.148357 UTC+0000 - opus - DEBUG - APP: Found 9 files created since the last powerup and less than 10 minutes old 23 | 2025-04-19 04:44:23.152385 UTC+0000 - opus - DEBUG - APP: C:\MESSUNGEN\20250419\ma20250419s0e00a.0009 - Could not determine peak position ([Errno 13] Permission denied: 'C:\MESSUNGEN\20250419\ma20250419s0e00a.0009') 24 | 2025-04-19 04:44:23.204388 UTC+0000 - opus - DEBUG - APP: C:\MESSUNGEN\20250419\ma20250419s0e00a.0008 - Found peak position 57123 (ABP = 60237, DC amplitude = 0.03186991322785616) 25 | 2025-04-19 04:44:23.258355 UTC+0000 - opus - DEBUG - APP: C:\MESSUNGEN\20250419\ma20250419s0e00a.0007 - Found peak position 57123 (ABP = 60237, DC amplitude = 0.0316972940415144) 26 | 2025-04-19 04:44:23.316386 UTC+0000 - opus - DEBUG - APP: C:\MESSUNGEN\20250419\ma20250419s0e00a.0006 - Found peak position 57123 (ABP = 60237, DC amplitude = 0.03151779551059008) 27 | 2025-04-19 04:44:23.371357 UTC+0000 - opus - DEBUG - APP: C:\MESSUNGEN\20250419\ma20250419s0e00a.0005 - Found peak position 57123 (ABP = 60237, DC amplitude = 0.031326398849487305) 28 | 2025-04-19 04:44:23.427355 UTC+0000 - opus - DEBUG - APP: C:\MESSUNGEN\20250419\ma20250419s0e00a.0004 - Found peak position 57123 (ABP = 60237, DC amplitude = 0.031152355410158636) 29 | 2025-04-19 04:44:23.430355 UTC+0000 - opus - DEBUG - APP: Currently configured ABP is 60237 30 | 2025-04-19 04:44:23.434366 UTC+0000 - opus - DEBUG - Currently recorded interferograms have peak positions: 57123 (-5 points offset from center) 31 | 2025-04-19 04:44:23.437387 UTC+0000 - opus - INFO - Updating peak position from 60237 to 60232 32 | 2025-04-19 04:44:23.512354 UTC+0000 - opus - DEBUG - Sleeping 19.51 seconds 33 | ``` 34 | 35 | Ensure to unload the interferogram files frequently because the files loaded by OPUS cannot be read by any other program. Otherwise, you will see many `Permission denied` exceptions until Pyra finds the first interferogram file from which to read the peak position. 36 | -------------------------------------------------------------------------------- /packages/docs/docs/user-guide/faq.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 9 3 | sidebar_label: FAQ 4 | hide_table_of_contents: true 5 | --- 6 | 7 | # FAQ 8 | 9 | ## Can we use Pyra on an offline system? 10 | 11 | Yes. Pyra only requires an internet connection for the installation. Pyra releases from 4.0.8 download astronomical data (until 2053) during installation, so no further internet connection during operation is required. 12 | 13 | ## During Setup, the following error occurs: "executing `.ps1` scripts is not allowed due to an ExecutionPolicy" 14 | 15 | This can be solved by running the following command in a PowerShell (credits to https://stackoverflow.com/a/49112322/8255842): 16 | 17 | ```powershell 18 | Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy Unrestricted 19 | ``` 20 | 21 | ## Pyra Shows a `filelock._error.Timeout` Exception 22 | 23 | In theory, Pyra may encounter a deadlock when writing and reading state or log files. It is very rare (< once per instrument per 5 years) and mostly self-resolving since Pyra 4.2.1. The error message will say something like this: 24 | 25 | ``` 26 | filelock._error.Timeout: The file lock 'C:\Users\ga56fem\Downloads\pyra\pyra-4.0.6\config\.state.lock' could not be acquired. 27 | ``` 28 | 29 | Stopping and restarting Pyra core resolves this error. 30 | 31 | :::note 32 | 33 | We are using the Python library `filelock` to enforce exclusive use of state- and log-files: There should not be two processes simultaneously interacting with one of these files. This [semaphore]() is necessary because Pyra runs many threads accessing these files in parallel. 34 | 35 | ::: 36 | 37 | ## Pyra Shuts Down The CamTracker Very Frequently 38 | 39 | The config setting `config.camtracker.motor_offset_threshold`, set to `10` degrees by default, controls the shutdown of CamTracker if it drifts too much. 40 | 41 | CamTracker reports its motor's offset from the theoretical sun position every few seconds. When CamTracker has been running for at least 5 minutes, Pyra will check whether that reported offset is greater than the threshold. If so, Pyra will restart the CamTracker because this might mean that the CamTracker has lost the sun and is tracking something else in the sky. 42 | 43 | In normal operation, you would see (debug) log lines like this every iteration: 44 | 45 | ```log 46 | DEBUG - CamTracker motor position is valid 47 | ``` 48 | 49 | If the motor position is not valid, you will see something like this: 50 | 51 | ```log 52 | INFO - CamTracker motor position is over threshold. 53 | INFO - Stopping CamTracker. Preparing for reinitialization. 54 | ``` 55 | 56 | If that restart happens too often for your use case, you can increase this factor - or set it to `360` to disable it completely. 57 | 58 | ## Exception "OPUS HTTP interface did not start within 90 seconds" 59 | 60 | When setting up a new system, it can happen that Pyra cannot connect to OPUS via the HTTP interface. 61 | 62 | 1. The first time you start Pyra core, you will be asked to grant it some firewall permissions – in a Windows popup. 63 | 64 | 2. After that, it is possible that some other software is using the localhost with a webserver. You have to turn off this other software or move it to another port. Common software being enabled by default: "Microsoft Internet Information Service" and "XAMPP". 65 | 66 | ## The system storage is very full even though there is not much data on it 67 | 68 | Due to the 24/7 operation of the systems, Windows might collect large amounts of temporary files and not remove them on restarts. We cannot influence this behavior but only raise an exception when the system's storage is filled up by more than 90%. 69 | 70 | You can use the app [TreeSize](https://apps.microsoft.com/detail/xpddxv3sd1sb5k) or its free version [TreeSize Free](https://apps.microsoft.com/detail/xp9m26rsclnt88) to check where the storage is used. 71 | -------------------------------------------------------------------------------- /packages/docs/docs/user-guide/functionality.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | hide_table_of_contents: true 4 | --- 5 | 6 | # Functionality 7 | 8 | Measurements with an EM27/SUN require you to run CamTracker - the software that tracks the sun position and operates the mirrors accordingly - and OPUS - the software that sends commands to the EM27/SUN and collects the output data into interferogram files. Fully automated setups usually have a weather protection enclosure and possibly custom hardware to evaluate measurement conditions. 9 | 10 | 11 | 12 | Pyra bridges the gap between the components required to automate your EM27/SUN setup. Based on certain conditions - time, sun angle, or sun condition (see [User Guide > TUM PLC and Helios](/docs/user-guide/tum-enclosure-and-helios)) - Pyra decides whether to perform measurements or not. This decision can also be outsourced to your own logic and passed to Pyra via CLI commands or done manually with a button in the UI (see [User Guide > Measurement Modes](/docs/user-guide/measurement-modes)). 13 | 14 | 15 | 16 | Pyra monitors the measurement process and operating system stability (disk space, CPU usage, etc.), permanently logs its activity, and sends emails when errors occur/have been resolved. 17 | -------------------------------------------------------------------------------- /packages/docs/docs/user-guide/measurement-modes.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | sidebar_label: Measurement Modes 4 | hide_table_of_contents: true 5 | --- 6 | 7 | # Measurement Modes 8 | 9 | The **"measurement decision"** (whether measurements should run or not) depends on the **"measurement mode"**: 10 | 11 | - **Automatic mode:** Measurement decision is made based on defined triggers (see below) 12 | - **Manual mode:** A start/stop button for manual control 13 | - **CLI mode:** The config contains a field where the CLI can add a decision result 14 | 15 | Pyra's measurement mode can be selected in the overview tab. In the logs and the codebase, this system of determining whether to measure or not is called **Condition Assessment System (CAS)**. 16 | 17 | 18 | 19 | ## Automatic Mode 20 | 21 | In automatic mode, Pyra does measurements when certain conditions are met: 22 | 23 | - start/stop time 24 | - minimum sun elevation 25 | - Helios 26 | 27 | In the configuration tab, you can select measurement triggers that should be considered in automatic mode. When multiple triggers are set, all triggers must be positive to start measurements (e.g., "above a certain sun elevation AND between start and end time"). 28 | 29 | 30 | 31 | **Helios** is our module to determine whether direct sunlight (required by the EM27/SUN) is present. Please read about it [here](/docs/user-guide/tum-enclosure-and-helios). 32 | 33 | ## Manual Mode 34 | 35 | In manual mode, the system will always measure when the sun is about the value configured at `config.general.min_sun_elevation` (you can set this to negative values too). 36 | 37 | ## CLI Mode 38 | 39 | CLI mode can be used when you have already built a system that evaluates measurement conditions and want to tell Pyra when to start and stop measurements from an external source. Respective CLI commands are in the [next section](#cli-command-line-interface). 40 | -------------------------------------------------------------------------------- /packages/docs/docs/user-guide/tum-enclosure-and-helios.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 8 3 | hide_table_of_contents: true 4 | --- 5 | 6 | # TUM Enclosure & Helios 7 | 8 | The TUM weather protection enclosure and Helios setup hardware are not available to buy off the shelf yet. Please [contact us](/docs/intro/contact) if you want to acquire our hardware. 9 | 10 | 11 | 12 | The TUM hardware used by [MUCCnet](https://atmosphere.ei.tum.de/) has been described in [Dietrich et al. 2021 (doi.org/10.5194/amt-14-1111-2021)](https://doi.org/10.5194/amt-14-1111-2021) and [Heinle et al. 2018 (doi.org/10.5194/amt-11-2173-2018)](https://doi.org/10.5194/amt-11-2173-2018). 13 | 14 | :::tip 15 | 16 | Pyra does not have power over the PLC concerning safety-related functions. When the PLC is in the "rain was detected" state, the cover will move to its parking position at 0°. Pyra (or any other process on the PC) cannot change that state or move the cover out of the parking position. 17 | 18 | ::: 19 | 20 | ## What does Helios do? 21 | 22 | Helios evaluates the current sun state - whether direct or diffuse sunlight exists. EM27/SUN applications require direct sunlight for measurement data to be useful. Additionally, CamTracker will lose track of the sun with very diffuse light conditions and requires a restart. 23 | 24 | The Helios hardware comprises a transparent globe with black stripes glued to it and a translucent milky glass with a camera pointing upwards attached below. 25 | 26 |
27 | 31 |
32 | 33 | Helios will periodically take images with that camera, process them, and evaluate shadow conditions. The processing will detect the lens circle and cut off the outer 10% of the radius - a lot of unwanted shadows can occur on the edges due to dirt from the outside. Finally, it will use a canny edge filter and count the pixels where "fast transitions between light and dark" can be found. If the pixel count is above a certain threshold (configurable), the sunlight conditions are categorized as "good". 34 | 35 | Example images in **bad** conditions: 36 | 37 |
38 | 42 | 46 |
47 | 48 | Example images in **good** conditions: 49 | 50 |
51 | 55 | 59 |
60 | -------------------------------------------------------------------------------- /packages/docs/docs/user-guide/upload.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 7 3 | sidebar_label: Upload 4 | hide_table_of_contents: true 5 | --- 6 | 7 | # Upload 8 | 9 | You can use this upload to transfer your interferograms, local pressure files, Pyra logs, helios images, or any other data generated day by day. 10 | 11 | You can upload daily directories like this: 12 | 13 | ``` 14 | 📁 data-directory-1 15 | ├── 📁 20190101 16 | │ ├── 📄 file1.txt 17 | │ ├── 📄 file2.txt 18 | │ └── 📄 file3.txt 19 | └── 📁 20190102 20 | ├── 📄 file1.txt 21 | ├── 📄 file2.txt 22 | └── 📄 file3.txt 23 | ``` 24 | 25 | Or daily files like this: 26 | 27 | ``` 28 | 📁 data-directory-2 29 | ├── 📄 20190101.txt 30 | ├── 📄 20190102-a.txt 31 | ├── 📄 20190102-b.txt 32 | └── 📄 20190103.txt 33 | ``` 34 | 35 | You just have to point the upload to one or more directories and specify a "date regex" - i.e., a regex that contains the symbols `%Y`, `%m`, and `%d` that matches your file/directory naming scheme. 36 | 37 | As long as the upload runs, there will be a file `data-directory-1/20190101/.do-not-touch` or `data-directory-2/.do-not-touch` that indicates that the upload is not finished yet. This file will be deleted after the checksum of the local and remote directory matches. 38 | 39 | Optionally, you can remove the local data, once the upload is done. 40 | 41 | You can read more about the `circadian-scp-upload` library [here](https://github.com/dostuffthatmatters/circadian-scp-upload). We have already uploaded about 8TB of data using this upload code and use it since 2022. The library has CI tests for all kinds of file system layouts - feel free to use it for your own projects :) 42 | -------------------------------------------------------------------------------- /packages/docs/docs/user-guide/usage-overview.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | sidebar_label: Usage Overview 4 | hide_table_of_contents: true 5 | --- 6 | 7 | # Usage Overview 8 | 9 | :::tip 10 | 11 | Starting and stopping measurements and controlling PYRA from another system/automation will be covered in the next section [Measurement Modes](/docs/user-guide/measurement-modes). 12 | 13 | ::: 14 | 15 | ## Parts of Pyra 16 | 17 | The three parts of Pyra are: 18 | 19 | - **Pyra Core:** Running in the background, looping over all automation steps infinitely in a defined interval. 20 | - **Pyra CLI:** Command Line Interface (CLI) used to interact with Pyra Core 21 | - **Pyra UI:** Graphical user Interface (GUI) used to interact with Pyra Core and send Pyra CLI commands 22 | 23 | ## Pyra Core 24 | 25 | Please read the [Developer Guide](/docs/developer-guide/architecture) for an overview of how the core is implemented. 26 | 27 | ## GUI (Graphical User Interface) 28 | 29 | This is how the GUI looks. The tab "PLC Controls" only appears when you have configured the [TUM PLC](/docs/user-guide/tum-enclosure-and-helios). 30 | 31 | 32 | 33 | When changes are made to the `config.json` file directly (not from the GUI), a popup will appear, telling you to reload the window: 34 | 35 | 36 | 37 | :::note 38 | 39 | We are currently experiencing a bug on some systems that the "config has changed, reload required" popup shows up when changes were made through the GUI. Also, the popup appears multiple times. This should be resolved in the future. 40 | 41 | The issue progress can be tracked here: https://github.com/tum-esm/pyra/issues/106 42 | 43 | ::: 44 | 45 | ## Logs 46 | 47 | Pyra will store all of its activity in log files. The files `info.log` and `debug.log` will only contain logs from the past hour. Older log lines will be archived periodically to keep the main log files easy to parse. 48 | 49 | ``` 50 | 📁 51 | 📁 pyra 52 | 📁 pyra-x.y.z 53 | 📁 logs 54 | 📄 info.log 55 | 📄 debug.log 56 | 📁 archive 57 | 📄 YYYYMMDD-info.log 58 | 📄 YYYYMMDD-debug.log 59 | 📄 ... 60 | ``` 61 | 62 | Inside the Logs tab, the content from the log files will be fetched every 10 seconds. The debug level includes more information. The log fetching can be paused (on the "live" button) for analyzing logs in detail. There is a button that opens the `pyra/pyra-x.y.z/logs` folder in the file explorer. 63 | 64 | 65 | 66 | ## Error Emails 67 | 68 | Whenever Pyra encounters any errors, it sends out emails to a list of recipients. An "error resolved" email helps with self-resolving issues and team communication - so that others get notified when the problem is not present anymore. 69 | 70 | **When errors occur:** 71 | 72 | 76 | 77 | **When errors are resolved:** 78 | 79 | 83 | 84 | :::note 85 | 86 | The "CoverError" comes from the TUM PLC Module controlling our weather protection enclosure. 87 | 88 | ::: 89 | -------------------------------------------------------------------------------- /packages/docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Note: type annotations allow type checking and IDEs autocompletion 3 | 4 | const lightCodeTheme = require("prism-react-renderer/themes/github"); 5 | const darkCodeTheme = require("prism-react-renderer/themes/dracula"); 6 | 7 | async function createConfig() { 8 | const mdxMermaid = await import("mdx-mermaid"); 9 | 10 | /** @type {import('@docusaurus/types').Config} */ 11 | return { 12 | title: "PYRA 4", 13 | tagline: "operate an EM27 station autonomously", 14 | staticDirectories: ["static"], 15 | url: "https://your-docusaurus-test-site.com", 16 | baseUrl: "/", 17 | onBrokenLinks: "throw", 18 | onBrokenMarkdownLinks: "throw", 19 | favicon: "img/favicon.ico", 20 | 21 | // GitHub pages deployment config. 22 | organizationName: "tum-esm", 23 | projectName: "pyra", 24 | 25 | // Even if you don't use internalization, you can use this field to set useful 26 | // metadata like html lang. 27 | i18n: { 28 | defaultLocale: "en", 29 | locales: ["en"], 30 | }, 31 | 32 | plugins: ["docusaurus-plugin-sass"], 33 | 34 | presets: [ 35 | [ 36 | "classic", 37 | /** @type {import('@docusaurus/preset-classic').Options} */ 38 | ({ 39 | docs: { 40 | sidebarPath: require.resolve("./sidebars.js"), 41 | // Remove this to remove the "edit this page" links. 42 | editUrl: "https://github.com/tum-esm/pyra/tree/main/website/", 43 | remarkPlugins: [mdxMermaid.default], 44 | }, 45 | theme: { 46 | customCss: [ 47 | require.resolve("./src/css/fonts.css"), 48 | require.resolve("./src/css/custom.scss"), 49 | ], 50 | }, 51 | }), 52 | ], 53 | ], 54 | 55 | themeConfig: 56 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 57 | ({ 58 | colorMode: { 59 | defaultMode: "light", 60 | disableSwitch: true, 61 | respectPrefersColorScheme: false, 62 | }, 63 | navbar: { 64 | title: "PYRA 4", 65 | logo: { 66 | alt: "PYRA 4 Logo", 67 | src: "img/logo.svg", 68 | width: 32, 69 | height: 32, 70 | }, 71 | items: [ 72 | { 73 | href: "https://github.com/tum-esm/pyra", 74 | position: "right", 75 | className: "header-github-link", 76 | }, 77 | ], 78 | hideOnScroll: true, 79 | }, 80 | footer: { 81 | style: "dark", 82 | copyright: `© ${new Date().getFullYear()} - Associate Professorship of Environmental Sensing and Modeling - Technical University of Munich`, 83 | }, 84 | prism: { 85 | theme: require("prism-react-renderer/themes/github"), 86 | additionalLanguages: ["python"], 87 | }, 88 | algolia: { 89 | appId: "HVYNW5V940", 90 | apiKey: "6f156c973f748938883016d17df0fbf7", // public API key 91 | indexName: "pyra-4", 92 | searchPagePath: false, 93 | }, 94 | }), 95 | }; 96 | } 97 | 98 | module.exports = createConfig; 99 | -------------------------------------------------------------------------------- /packages/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "dev": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "^2.4.0", 19 | "@docusaurus/preset-classic": "^2.4.0", 20 | "@mdx-js/react": "^1.6.22", 21 | "@reduxjs/toolkit": "^1.9.3", 22 | "@tabler/icons-react": "^3.31.0", 23 | "@tauri-apps/api": "^1.2.0", 24 | "clsx": "^1.2.1", 25 | "deep-diff": "^1.0.2", 26 | "docusaurus-plugin-sass": "^0.2.3", 27 | "lodash": "^4.17.21", 28 | "mdx-mermaid": "^1.3.2", 29 | "mermaid": "^9.1.6", 30 | "moment": "^2.29.4", 31 | "prism-react-renderer": "^1.3.5", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0", 34 | "react-hot-toast": "^2.4.0", 35 | "react-redux": "^8.0.5", 36 | "sass": "^1.59.3", 37 | "socket.io-client": "^4.6.1", 38 | "typedoc": "^0.23.28", 39 | "typedoc-plugin-markdown": "^3.14.0" 40 | }, 41 | "devDependencies": { 42 | "@docusaurus/module-type-aliases": "^2.4.0", 43 | "@tsconfig/docusaurus": "^1.0.5", 44 | "autoprefixer": "^10.4.8", 45 | "docusaurus-plugin-typedoc": "^0.18.0", 46 | "postcss": "^8.4.16", 47 | "tailwindcss": "^3.1.8", 48 | "typescript": "^4.7.4" 49 | }, 50 | "browserslist": { 51 | "production": [ 52 | ">0.5%", 53 | "not dead", 54 | "not op_mini all" 55 | ], 56 | "development": [ 57 | "last 1 chrome version", 58 | "last 1 firefox version", 59 | "last 1 safari version" 60 | ] 61 | }, 62 | "engines": { 63 | "node": ">=20.13" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/docs/sidebars.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 4 | const sidebars = { 5 | tutorialSidebar: [{ type: 'autogenerated', dirName: '.' }], 6 | }; 7 | 8 | module.exports = sidebars; 9 | -------------------------------------------------------------------------------- /packages/docs/src/components/github-label.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function GitHubLabel(props: { text: string }): JSX.Element { 4 | return ( 5 | 6 | {props.text} 7 | 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /packages/docs/src/components/track-on-github.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import GitHubLabel from '@site/src/components/github-label'; 4 | 5 | export default function TrackOnGitHub(props: { 6 | url: string; 7 | text: string; 8 | releaseDate?: string; 9 | }): JSX.Element { 10 | return ( 11 |
12 | 16 | Track on GitHub 17 | 18 | Release Date: {props.releaseDate || 'TBD'} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /packages/docs/src/css/components/navbar.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/src/css/components/navbar.scss -------------------------------------------------------------------------------- /packages/docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | } 12 | 13 | @media screen and (max-width: 996px) { 14 | .heroBanner { 15 | padding: 2rem; 16 | } 17 | } 18 | 19 | .buttons { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | -------------------------------------------------------------------------------- /packages/docs/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | 4 | export default function Home() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /packages/docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/.nojekyll -------------------------------------------------------------------------------- /packages/docs/static/drawings/exports/package-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/drawings/exports/package-architecture.png -------------------------------------------------------------------------------- /packages/docs/static/drawings/exports/setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/drawings/exports/setup.png -------------------------------------------------------------------------------- /packages/docs/static/drawings/exports/state-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/drawings/exports/state-architecture.png -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-500.woff -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-500.woff2 -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-500italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-500italic.woff -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-500italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-500italic.woff2 -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-600.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-600.woff -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-600.woff2 -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-600italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-600italic.woff -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-600italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-600italic.woff2 -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-700.woff -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-700.woff2 -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-700italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-700italic.woff -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-700italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-700italic.woff2 -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-800.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-800.woff -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-800.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-800.woff2 -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-800italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-800italic.woff -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-800italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-800italic.woff2 -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-italic.woff -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-italic.woff2 -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-regular.woff -------------------------------------------------------------------------------- /packages/docs/static/fonts/rubik-v26-latin_latin-ext-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/fonts/rubik-v26-latin_latin-ext-regular.woff2 -------------------------------------------------------------------------------- /packages/docs/static/img/docs/cli-config-update-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/cli-config-update-example.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/config-file-reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/config-file-reload.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/email-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/email-config.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/email-error-occured.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/email-error-occured.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/email-errors-resolved.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/email-errors-resolved.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/environment-path-manual-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/environment-path-manual-1.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/environment-path-manual-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/environment-path-manual-2.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/environment-path-manual-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/environment-path-manual-3.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/environment-path-manual-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/environment-path-manual-4.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/helios-example-image-bad-processed.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/helios-example-image-bad-processed.jpg -------------------------------------------------------------------------------- /packages/docs/static/img/docs/helios-example-image-bad-raw.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/helios-example-image-bad-raw.jpg -------------------------------------------------------------------------------- /packages/docs/static/img/docs/helios-example-image-good-processed.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/helios-example-image-good-processed.jpg -------------------------------------------------------------------------------- /packages/docs/static/img/docs/helios-example-image-good-raw.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/helios-example-image-good-raw.jpg -------------------------------------------------------------------------------- /packages/docs/static/img/docs/helios-hardware.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/helios-hardware.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/jia-profile-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/jia-profile-image.jpg -------------------------------------------------------------------------------- /packages/docs/static/img/docs/logs-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/logs-tab.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/measurement-modes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/measurement-modes.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/moritz-profile-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/moritz-profile-image.jpg -------------------------------------------------------------------------------- /packages/docs/static/img/docs/muccnet-image-roof.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/muccnet-image-roof.jpg -------------------------------------------------------------------------------- /packages/docs/static/img/docs/overview-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/overview-tab.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/patrick-profile-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/patrick-profile-image.jpg -------------------------------------------------------------------------------- /packages/docs/static/img/docs/pyra-cli-path-request.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/pyra-cli-path-request.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/python-310-path-automatic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/python-310-path-automatic.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/triggers-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/triggers-config.png -------------------------------------------------------------------------------- /packages/docs/static/img/docs/who-made-this-meme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/docs/who-made-this-meme.png -------------------------------------------------------------------------------- /packages/docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /packages/docs/static/img/icons/icon-cheveron-up.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /packages/docs/static/logo/pyra-4-icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/logo/pyra-4-icon-1024.png -------------------------------------------------------------------------------- /packages/docs/static/logo/pyra-4-icon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/logo/pyra-4-icon-256.png -------------------------------------------------------------------------------- /packages/docs/static/logo/pyra-4-icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/docs/static/logo/pyra-4-icon-512.png -------------------------------------------------------------------------------- /packages/docs/static/logo/pyra-4-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /packages/docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // prettier-ignore 2 | 3 | const colors = require('tailwindcss/colors') 4 | const customColors = { 5 | gray: { 6 | 50: '#f8fafc', 7 | 75: '#f4f7fa', 8 | 100: '#f1f5f9', 9 | 150: '#e9eef4', 10 | 200: '#e2e8f0', 11 | 300: '#cbd5e1', 12 | 350: '#afbccc', 13 | 400: '#94a3b8', 14 | 500: '#64748b', 15 | 600: '#475569', 16 | 700: '#334155', 17 | 750: '#283548', 18 | 800: '#1e293b', 19 | 850: '#162032', 20 | 900: '#0f172a', 21 | 950: '#0B111F', 22 | }, 23 | 24 | red: colors.red, 25 | green: colors.green, 26 | yellow: colors.yellow, 27 | 28 | blue: { 29 | 50: '#DCEEFB', 30 | 75: '#c9e7fc', 31 | 100: '#B6E0FE', 32 | 150: '#9DD2F9', 33 | 200: '#84C5F4', 34 | 300: '#62B0E8', 35 | 400: '#4098D7', 36 | 500: '#2680C2', 37 | 600: '#186FAF', 38 | 700: '#0F609B', 39 | 800: '#0A558C', 40 | 900: '#003E6B', 41 | }, 42 | 43 | teal: { 44 | 50: '#f0fdfa', 45 | 75: '#defcf5', 46 | 100: '#ccfbf1', 47 | 200: '#99f6e4', 48 | 300: '#5eead4', 49 | 400: '#2dd4bf', 50 | 500: '#14b8a6', 51 | 600: '#0d9488', 52 | 700: '#0f766e', 53 | 800: '#115e59', 54 | 900: '#134e4a', 55 | 950: '#092725', 56 | }, 57 | }; 58 | 59 | module.exports = { 60 | content: ['./src/**/*.{js,jsx,ts,tsx,css,scss}', './docs/**/*.{md,mdx}'], 61 | theme: { 62 | extend: { 63 | colors: customColors, 64 | fill: customColors, 65 | }, 66 | }, 67 | plugins: [], 68 | prefix: 'tw-', 69 | }; 70 | -------------------------------------------------------------------------------- /packages/docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@tsconfig/docusaurus/tsconfig.json", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/ui/.env.example: -------------------------------------------------------------------------------- 1 | VITE_PYTHON_INTERPRETER="system|venv" 2 | VITE_PYRA_DIRECTORY="~/Documents/pyra/pyra-4.2.2" 3 | -------------------------------------------------------------------------------- /packages/ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | !src/lib 10 | 11 | # Editor directories and files 12 | .vscode/* 13 | !.vscode/extensions.json 14 | .idea 15 | .DS_Store 16 | *.suo 17 | *.ntvs* 18 | *.njsproj 19 | *.sln 20 | *.sw? 21 | 22 | # other 23 | node_modules 24 | dist 25 | dist-ssr 26 | *.local -------------------------------------------------------------------------------- /packages/ui/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/bun.lockb -------------------------------------------------------------------------------- /packages/ui/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/styles/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "src/components", 14 | "utils": "src/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Pyra UI 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pyra-ui", 3 | "version": "4.2.2", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite --port 3000", 7 | "build": "tsc && vite build", 8 | "preview": "vite preview", 9 | "tauri": "tauri" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-checkbox": "^1.1.3", 13 | "@radix-ui/react-icons": "^1.3.2", 14 | "@radix-ui/react-select": "^2.1.4", 15 | "@radix-ui/react-separator": "^1.1.1", 16 | "@radix-ui/react-slot": "^1.1.1", 17 | "@reduxjs/toolkit": "^2.5.0", 18 | "@tabler/icons-react": "^3.26.0", 19 | "@tauri-apps/api": "^2.1.1", 20 | "@tauri-apps/plugin-cli": "^2.2.0", 21 | "@tauri-apps/plugin-dialog": "^2.2.0", 22 | "@tauri-apps/plugin-fs": "^2.2.0", 23 | "@tauri-apps/plugin-shell": "^2.2.0", 24 | "class-variance-authority": "^0.7.1", 25 | "clsx": "^2.1.1", 26 | "deep-diff": "^1.0.2", 27 | "lodash": "4.17.21", 28 | "moment": "^2.30.1", 29 | "react": "19.0.0", 30 | "react-dom": "19.0.0", 31 | "react-hot-toast": "^2.4.1", 32 | "recursive-diff": "^1.0.9", 33 | "tailwind-merge": "^2.5.5", 34 | "tailwindcss-animate": "^1.0.7", 35 | "zod": "^3.24.1", 36 | "zustand": "^5.0.2" 37 | }, 38 | "devDependencies": { 39 | "@tailwindcss/forms": "0.5.9", 40 | "@tauri-apps/cli": "^2.1.0", 41 | "@types/deep-diff": "^1.0.5", 42 | "@types/lodash": "4.17.13", 43 | "@types/node": "^22.10.2", 44 | "@types/react": "19.0.2", 45 | "@types/react-dom": "19.0.2", 46 | "@vitejs/plugin-react": "4.3.4", 47 | "autoprefixer": "10.4.20", 48 | "postcss": "8.4.49", 49 | "tailwindcss": "3.4.17", 50 | "tilg": "0.1.1", 51 | "typedoc": "^0.27.5", 52 | "typescript": "5.7.2", 53 | "vite": "^6.0.4" 54 | }, 55 | "prettier": { 56 | "trailingComma": "es5", 57 | "tabWidth": 4, 58 | "semi": true, 59 | "singleQuote": true, 60 | "printWidth": 100 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/ui/src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | WixTools 5 | -------------------------------------------------------------------------------- /packages/ui/src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pyra-ui" 3 | version = "4.2.2" 4 | description = "Pyra UI" 5 | authors = ["Moritz Makowski "] 6 | license = "" 7 | repository = "https://github.com/tum-esm/pyra" 8 | default-run = "pyra-ui" 9 | edition = "2021" 10 | rust-version = "1.57" 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [build-dependencies] 15 | tauri-build = { version = "2", features = [] } 16 | 17 | [dependencies] 18 | serde_json = "1.0" 19 | serde = { version = "1.0", features = ["derive"] } 20 | tauri = { version = "2", features = ["protocol-asset", "devtools"] } 21 | tauri-plugin-shell = "2" 22 | tauri-plugin-fs = "2" 23 | tauri-plugin-dialog = "2" 24 | 25 | [features] 26 | # by default Tauri runs in production mode 27 | # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL 28 | default = ["custom-protocol"] 29 | # this feature is used used for production builds where `devPath` points to the filesystem 30 | # DO NOT remove this 31 | custom-protocol = ["tauri/custom-protocol"] 32 | -------------------------------------------------------------------------------- /packages/ui/src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /packages/ui/src-tauri/capabilities/migrated.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "migrated", 3 | "description": "permissions that were migrated from v1", 4 | "local": true, 5 | "windows": [ 6 | "main" 7 | ], 8 | "permissions": [ 9 | "core:default", 10 | "fs:allow-read-file", 11 | { 12 | "identifier": "fs:scope", 13 | "allow": [ 14 | "$DOWNLOAD/pyra/**", 15 | "$DOCUMENT/pyra/**", 16 | "$DOCUMENT/work/esm/pyra/**" 17 | ] 18 | }, 19 | { 20 | "identifier": "shell:allow-execute", 21 | "allow": [ 22 | { 23 | "args": true, 24 | "cmd": "python", 25 | "name": "system-python", 26 | "sidecar": false 27 | }, 28 | { 29 | "args": true, 30 | "cmd": ".venv/bin/python", 31 | "name": "venv-python", 32 | "sidecar": false 33 | } 34 | ] 35 | }, 36 | "shell:allow-open", 37 | "dialog:allow-open", 38 | "dialog:allow-message", 39 | "dialog:allow-ask", 40 | "dialog:allow-confirm", 41 | "shell:default", 42 | "fs:default", 43 | "dialog:default" 44 | ] 45 | } -------------------------------------------------------------------------------- /packages/ui/src-tauri/gen/schemas/capabilities.json: -------------------------------------------------------------------------------- 1 | {"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main"],"permissions":["core:default","fs:allow-read-file",{"identifier":"fs:scope","allow":["$DOWNLOAD/pyra/**","$DOCUMENT/pyra/**","$DOCUMENT/work/esm/pyra/**"]},{"identifier":"shell:allow-execute","allow":[{"args":true,"cmd":"python","name":"system-python","sidecar":false},{"args":true,"cmd":".venv/bin/python","name":"venv-python","sidecar":false}]},"shell:allow-open","dialog:allow-open","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","shell:default","fs:default","dialog:default"]}} -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /packages/ui/src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /packages/ui/src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | fn main() { 7 | tauri::Builder::default() 8 | .plugin(tauri_plugin_dialog::init()) 9 | .plugin(tauri_plugin_fs::init()) 10 | .plugin(tauri_plugin_shell::init()) 11 | .run(tauri::generate_context!()) 12 | .expect("error while running tauri application"); 13 | } 14 | -------------------------------------------------------------------------------- /packages/ui/src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/schema.json", 3 | "build": { 4 | "beforeBuildCommand": "bun run build", 5 | "beforeDevCommand": "bun run dev", 6 | "frontendDist": "../dist", 7 | "devUrl": "http://localhost:3000" 8 | }, 9 | "bundle": { 10 | "active": true, 11 | "category": "DeveloperTool", 12 | "copyright": "", 13 | "targets": "all", 14 | "externalBin": [], 15 | "icon": [ 16 | "icons/32x32.png", 17 | "icons/128x128.png", 18 | "icons/128x128@2x.png", 19 | "icons/icon.icns", 20 | "icons/icon.ico" 21 | ], 22 | "windows": { 23 | "certificateThumbprint": null, 24 | "digestAlgorithm": "sha256", 25 | "timestampUrl": "" 26 | }, 27 | "longDescription": "", 28 | "macOS": { 29 | "entitlements": null, 30 | "exceptionDomain": "", 31 | "frameworks": [], 32 | "providerShortName": null, 33 | "signingIdentity": null 34 | }, 35 | "resources": [], 36 | "shortDescription": "", 37 | "linux": { 38 | "deb": { 39 | "depends": [] 40 | } 41 | } 42 | }, 43 | "productName": "Pyra UI", 44 | "mainBinaryName": "Pyra UI", 45 | "version": "4.2.2", 46 | "identifier": "pyra-ui", 47 | "plugins": {}, 48 | "app": { 49 | "windows": [ 50 | { 51 | "fullscreen": false, 52 | "resizable": true, 53 | "title": "Pyra UI", 54 | "height": 620, 55 | "minHeight": 620, 56 | "width": 1000, 57 | "minWidth": 1000, 58 | "center": true, 59 | "useHttpsScheme": true 60 | } 61 | ], 62 | "security": { 63 | "assetProtocol": { 64 | "scope": ["$DOWNLOAD/pyra/**", "$DOCUMENT/pyra/**", "$DOCUMENT/work/esm/pyra/**"], 65 | "enable": true 66 | }, 67 | "csp": null 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/ui/src/assets/green-tum-logo_1024x.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src/assets/green-tum-logo_1024x.icns -------------------------------------------------------------------------------- /packages/ui/src/assets/green-tum-logo_1024x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/packages/ui/src/assets/green-tum-logo_1024x.png -------------------------------------------------------------------------------- /packages/ui/src/assets/icons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const SVG = (props: { children: React.ReactNode; id?: string }) => ( 4 | 14 | 15 | {props.children} 16 | 17 | ); 18 | 19 | const ICONS = { 20 | refresh: ( 21 | 22 | 23 | 24 | 25 | ), 26 | info: ( 27 | 28 | 29 | 30 | 31 | 32 | ), 33 | spinner: ( 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ), 45 | check: ( 46 | 47 | 48 | 49 | 50 | ), 51 | forbid: ( 52 | 53 | 54 | 55 | 56 | 57 | ), 58 | close: ( 59 | 60 | 61 | 62 | 63 | ), 64 | alert: ( 65 | 66 | 67 | 68 | 69 | ), 70 | }; 71 | 72 | export default ICONS; 73 | -------------------------------------------------------------------------------- /packages/ui/src/assets/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ICONS } from './icons'; 2 | -------------------------------------------------------------------------------- /packages/ui/src/components/configuration/index.ts: -------------------------------------------------------------------------------- 1 | import LabeledRow from './rows/labeled-row'; 2 | import SavingOverlay from './saving-overlay'; 3 | import ConfigElementText from './rows/config-element-text'; 4 | import ConfigElementTime from './rows/config-element-time'; 5 | import { ConfigElementToggle, ConfigElementBooleanToggle } from './rows/config-element-toggle'; 6 | import ConfigSectionGeneral from './sections/config-section-general'; 7 | import ConfigSectionErrorEmail from './sections/config-section-error-email'; 8 | import ConfigSectionCamtracker from './sections/config-section-camtracker'; 9 | import ConfigSectionMeasurementTriggers from './sections/config-section-measurement-triggers'; 10 | import ConfigSectionOpus from './sections/config-section-opus'; 11 | import ConfigSectionTUMEnclosure from './sections/config-section-tum-enclosure'; 12 | import ConfigSectionHelios from './sections/config-section-helios'; 13 | import ConfigSectionUpload from './sections/config-section-upload'; 14 | import ConfigElementLine from './rows/config-element-line'; 15 | import ConfigElementNote from './rows/config-element-note'; 16 | 17 | export default { 18 | LabeledRow, 19 | SavingOverlay, 20 | ConfigElementText, 21 | ConfigElementTime, 22 | ConfigElementToggle, 23 | ConfigElementBooleanToggle, 24 | ConfigElementLine, 25 | ConfigElementNote, 26 | ConfigSectionCamtracker, 27 | ConfigSectionErrorEmail, 28 | ConfigSectionGeneral, 29 | ConfigSectionMeasurementTriggers, 30 | ConfigSectionOpus, 31 | ConfigSectionTUMEnclosure, 32 | ConfigSectionHelios, 33 | ConfigSectionUpload, 34 | }; 35 | -------------------------------------------------------------------------------- /packages/ui/src/components/configuration/rows/config-element-line.tsx: -------------------------------------------------------------------------------- 1 | const ConfigElementLine = () =>
; 2 | 3 | export default ConfigElementLine; 4 | -------------------------------------------------------------------------------- /packages/ui/src/components/configuration/rows/config-element-note.tsx: -------------------------------------------------------------------------------- 1 | const ConfigElementNote = (props: { children: React.ReactNode }) => ( 2 |
3 | {props.children} 4 |
5 | ); 6 | 7 | export default ConfigElementNote; 8 | -------------------------------------------------------------------------------- /packages/ui/src/components/configuration/rows/config-element-text.tsx: -------------------------------------------------------------------------------- 1 | import { configurationComponents, essentialComponents } from '../..'; 2 | import toast from 'react-hot-toast'; 3 | import * as dialog from '@tauri-apps/plugin-dialog'; 4 | import * as shell from '@tauri-apps/plugin-shell'; 5 | 6 | export default function ConfigElementText(props: { 7 | title: string; 8 | value: string | number; 9 | oldValue: string | number | undefined; 10 | setValue(v: string | number): void; 11 | disabled?: boolean; 12 | numeric?: boolean; 13 | postfix?: string; 14 | showSelector?: 'file' | 'directory'; 15 | }) { 16 | const { title, value, oldValue, setValue, disabled, numeric, postfix, showSelector } = props; 17 | 18 | async function triggerSelection() { 19 | const result: any = await dialog.open({ 20 | title: 'PyRa 4 UI', 21 | multiple: false, 22 | directory: showSelector === 'directory', 23 | }); 24 | if (result !== null) { 25 | setValue(result); 26 | } 27 | } 28 | 29 | function parseNumericValue(v: string): string { 30 | const parsedValue = `${v}`.replace(/[^\d\.\+\-]/g, ''); 31 | if (parsedValue === '') { 32 | return '0'; 33 | } 34 | return parsedValue; 35 | } 36 | 37 | async function openDirectory() { 38 | if (typeof value === 'string') { 39 | let path: string; 40 | if (value.includes('\\')) { 41 | path = value.split('\\').slice(0, -1).join('\\'); 42 | } else { 43 | path = value.split('/').slice(0, -1).join('/'); 44 | } 45 | await shell.open(path).catch(() => toast.error('Could not open directory')); 46 | } 47 | } 48 | 49 | let hasBeenModified: boolean; 50 | if (numeric) { 51 | hasBeenModified = oldValue !== (typeof value === 'string' ? parseFloat(value) : value); 52 | } else { 53 | hasBeenModified = oldValue !== value; 54 | } 55 | 56 | return ( 57 | 58 |
59 | setValue(numeric ? parseNumericValue(v) : v)} 62 | postfix={postfix} 63 | /> 64 | {showSelector && !disabled && ( 65 | <> 66 | 67 | select 68 | 69 | 70 | show in explorer 71 | 72 | 73 | )} 74 |
75 | 80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /packages/ui/src/components/configuration/rows/config-element-time.tsx: -------------------------------------------------------------------------------- 1 | import { configurationComponents, essentialComponents } from '../..'; 2 | import { renderTimeObject } from '../../../utils/functions'; 3 | 4 | export default function ConfigElementTime(props: { 5 | title: string; 6 | value: { hour: number; minute: number; second: number }; 7 | oldValue: { hour: number; minute: number; second: number }; 8 | setValue(v: { hour: number; minute: number; second: number }): void; 9 | disabled?: boolean; 10 | }) { 11 | const { title, value, oldValue, setValue, disabled } = props; 12 | 13 | function parseNumericValue(v: string): number { 14 | return parseInt(`${v}`.replace(/[^\d]/g, '')) || 0; 15 | } 16 | 17 | let hasBeenModified = 18 | value.hour !== oldValue.hour || 19 | value.minute !== oldValue.minute || 20 | value.second !== oldValue.second; 21 | 22 | return ( 23 | 24 |
25 | setValue({ ...value, hour: parseNumericValue(v) })} 28 | disabled={disabled} 29 | /> 30 | : 31 | setValue({ ...value, minute: parseNumericValue(v) })} 34 | disabled={disabled} 35 | /> 36 | : 37 | setValue({ ...value, second: parseNumericValue(v) })} 40 | disabled={disabled} 41 | /> 42 |
43 | 46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /packages/ui/src/components/configuration/rows/config-element-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { configurationComponents, essentialComponents } from '../..'; 2 | 3 | export function ConfigElementToggle(props: { 4 | title: string; 5 | value: string; 6 | values: string[]; 7 | oldValue: string | null; 8 | setValue(v: string): void; 9 | }) { 10 | return ( 11 | 15 | props.setValue(v)} 19 | /> 20 | 29 | 30 | ); 31 | } 32 | 33 | export function ConfigElementBooleanToggle(props: { 34 | title: string; 35 | value: boolean; 36 | oldValue: boolean | null; 37 | setValue(v: boolean): void; 38 | }) { 39 | return ( 40 | props.setValue(v === 'yes')} 46 | /> 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /packages/ui/src/components/configuration/rows/labeled-row.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function LabeledRow(props: { 4 | title: string; 5 | modified?: boolean; 6 | children: React.ReactNode; 7 | }) { 8 | const { title, modified, children } = props; 9 | 10 | return ( 11 |
12 | 22 |
{children}
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /packages/ui/src/components/configuration/saving-overlay.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '../ui/button'; 2 | 3 | export default function SavingOverlay(props: { 4 | errorMessage: undefined | string; 5 | onSave(): void; 6 | onRevert(): void; 7 | isSaving: boolean; 8 | }) { 9 | return ( 10 |
11 |
12 |
13 | {props.errorMessage === undefined && 'Save your changes now!'} 14 | {props.errorMessage !== undefined && 15 | props.errorMessage.replaceAll('Error in ', '')} 16 |
17 |
18 | 19 | 22 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /packages/ui/src/components/configuration/sections/config-section-general.tsx: -------------------------------------------------------------------------------- 1 | import { configurationComponents } from '../..'; 2 | import { useConfigStore } from '../../../utils/zustand-utils/config-zustand'; 3 | 4 | export default function ConfigSectionGeneral() { 5 | const { centralConfig, localConfig, setLocalConfigItem } = useConfigStore(); 6 | 7 | const centralSectionConfig = centralConfig?.general; 8 | const localSectionConfig = localConfig?.general; 9 | 10 | if (localSectionConfig === undefined || centralSectionConfig === undefined) { 11 | return <>; 12 | } 13 | return ( 14 | <> 15 | setLocalConfigItem('general.station_id', v)} 19 | oldValue={centralSectionConfig.station_id} 20 | /> 21 | 22 | Used in logs, emails and Helios images. 23 | 24 | 25 | 29 | setLocalConfigItem('general.seconds_per_core_iteration', v) 30 | } 31 | oldValue={centralSectionConfig.seconds_per_core_iteration} 32 | postfix="second(s)" 33 | numeric 34 | /> 35 | setLocalConfigItem('general.min_sun_elevation', v)} 39 | oldValue={centralSectionConfig.min_sun_elevation} 40 | postfix="degree(s)" 41 | numeric 42 | /> 43 | 44 | The EM27/SUN will power up three degrees below to warm up (only if enclosure is 45 | configured). Helios will start at this angle. Manual measurements will start at this 46 | angle. 47 | 48 | 49 | setLocalConfigItem('general.test_mode', v)} 53 | oldValue={centralSectionConfig.test_mode} 54 | /> 55 | 56 | Only used in development. Otherwise left at `no`. If enabled, Pyra does not connect 57 | to the enclosure hardware, OPUS and CamTracker so it can run on any computer. 58 | 59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /packages/ui/src/components/configuration/sections/config-section-tum-enclosure.tsx: -------------------------------------------------------------------------------- 1 | import { configurationComponents, essentialComponents } from '../..'; 2 | import { useConfigStore } from '../../../utils/zustand-utils/config-zustand'; 3 | import { Button } from '../../ui/button'; 4 | 5 | export default function ConfigSectionTUMEnclosure() { 6 | const { centralConfig, localConfig, setLocalConfigItem } = useConfigStore(); 7 | 8 | const centralSectionConfig = centralConfig?.tum_enclosure; 9 | const localSectionConfig = localConfig?.tum_enclosure; 10 | 11 | function addDefault() { 12 | setLocalConfigItem('tum_enclosure', { 13 | ip: '10.10.0.4', 14 | version: 1, 15 | controlled_by_user: false, 16 | }); 17 | } 18 | 19 | function setNull() { 20 | setLocalConfigItem('tum_enclosure', null); 21 | } 22 | 23 | if (localSectionConfig === undefined || centralSectionConfig === undefined) { 24 | return <>; 25 | } 26 | 27 | if (localSectionConfig === null) { 28 | return ( 29 |
30 | 31 | 40 | {centralSectionConfig !== null && ( 41 |
42 | )} 43 |
44 | ); 45 | } 46 | 47 | return ( 48 | <> 49 |
50 | 51 |
52 | 53 | setLocalConfigItem('tum_enclosure.ip', v)} 57 | oldValue={centralSectionConfig !== null ? centralSectionConfig.ip : 'null'} 58 | /> 59 | setLocalConfigItem('tum_enclosure.version', v)} 63 | oldValue={centralSectionConfig !== null ? centralSectionConfig.version : 'null'} 64 | numeric 65 | /> 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /packages/ui/src/components/essential/button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Spinner from './spinner'; 3 | 4 | export default function Button(props: { 5 | children: React.ReactNode; 6 | onClick(): void; 7 | variant: 'white' | 'green' | 'red' | 'gray' | 'flat-blue'; 8 | disabled?: boolean; 9 | className?: string; 10 | dot?: boolean; 11 | spinner?: boolean; 12 | }) { 13 | let { children, onClick, variant, className, spinner, disabled } = props; 14 | if (spinner) { 15 | disabled = true; 16 | } 17 | 18 | let colorClasses: string = ''; 19 | let dotColor: string = ' '; 20 | switch (variant) { 21 | case 'white': 22 | colorClasses = 'elevated-panel text-gray-800 hover:bg-gray-150 hover:text-gray-900 '; 23 | dotColor = 'bg-gray-300 '; 24 | break; 25 | case 'gray': 26 | colorClasses = 'elevated-panel '; 27 | if (props.disabled) { 28 | colorClasses += 'text-gray-400 !bg-gray-100 '; 29 | } else { 30 | colorClasses += 'text-gray-700 !bg-gray-75 hover:!bg-gray-200 hover:text-gray-950 '; 31 | } 32 | dotColor = 'bg-gray-300 '; 33 | break; 34 | case 'green': 35 | colorClasses = 'elevated-panel text-green-700 hover:bg-green-50 hover:text-green-900 '; 36 | dotColor = 'bg-green-300 '; 37 | break; 38 | case 'red': 39 | colorClasses = 'elevated-panel text-red-700 hover:bg-red-50 hover:text-red-900 '; 40 | dotColor = 'bg-red-300 '; 41 | break; 42 | case 'flat-blue': 43 | colorClasses = 44 | 'bg-green-100 rounded-md text-green-800 ' + 45 | 'hover:bg-blue-150 hover:text-blue-950 border border-green-300 '; 46 | dotColor = 'bg-blue-300 '; 47 | break; 48 | } 49 | return ( 50 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /packages/ui/src/components/essential/index.ts: -------------------------------------------------------------------------------- 1 | import Button from './button'; 2 | import LiveSwitch from './live-switch'; 3 | import { CoreLogLine, UILogLine } from './log-line'; 4 | import NumericButton from './numeric-button'; 5 | import Ping from './ping'; 6 | import PreviousValue from './previous-value'; 7 | import TextInput from './text-input'; 8 | import Toggle from './toggle'; 9 | import Spinner from './spinner'; 10 | 11 | export default { 12 | Button, 13 | LiveSwitch, 14 | CoreLogLine, 15 | UILogLine, 16 | NumericButton, 17 | Ping, 18 | PreviousValue, 19 | TextInput, 20 | Toggle, 21 | Spinner, 22 | }; 23 | -------------------------------------------------------------------------------- /packages/ui/src/components/essential/live-switch.tsx: -------------------------------------------------------------------------------- 1 | import { essentialComponents } from '..'; 2 | export default function LiveSwitch(props: { isLive: boolean; toggle(v: boolean): void }) { 3 | const { isLive, toggle } = props; 4 | 5 | return ( 6 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/ui/src/components/essential/numeric-button.tsx: -------------------------------------------------------------------------------- 1 | import { isNaN } from 'lodash'; 2 | import React, { useState } from 'react'; 3 | import Button from './button'; 4 | import TextInput from './text-input'; 5 | 6 | export default function NumericButton(props: { 7 | children: React.ReactNode; 8 | initialValue: number; 9 | onClick(value: number): void; 10 | disabled?: boolean; 11 | className?: string; 12 | spinner?: boolean; 13 | postfix?: string; 14 | }) { 15 | let { children, initialValue, onClick, className, spinner, disabled, postfix } = props; 16 | if (spinner) { 17 | disabled = true; 18 | } 19 | 20 | const [value, setValue] = useState(initialValue); 21 | return ( 22 |
23 | setValue(isNaN(parseInt(v)) ? 0 : parseInt(v))} 26 | postfix={postfix} 27 | disabled={props.disabled} 28 | className="!h-8" 29 | /> 30 | 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /packages/ui/src/components/essential/ping.tsx: -------------------------------------------------------------------------------- 1 | export default function Ping(props: { state: boolean | undefined }) { 2 | let color = 'bg-gray-500'; 3 | if (props.state === true) { 4 | color = 'bg-green-500'; 5 | } 6 | if (props.state === false) { 7 | color = 'bg-red-500'; 8 | } 9 | return ( 10 |
11 | {props.state === true && ( 12 |
13 | )} 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /packages/ui/src/components/essential/previous-value.tsx: -------------------------------------------------------------------------------- 1 | export default function PreviousValue(props: { previousValue?: string | number | any }) { 2 | const { previousValue } = props; 3 | const sharedClasses1 = 'ml-1 text-xs font-normal flex-row-left opacity-80 gap-x-1'; 4 | const sharedClasses2 = 5 | 'rounded-md bg-blue-100 border border-blue-300 px-1.5 py-0.5 text-blue-950 text-xs break-all'; 6 | 7 | if (typeof previousValue === 'string' || typeof previousValue === 'number') { 8 | return ( 9 | 10 | previous value: 11 | {previousValue} 12 | 13 | ); 14 | } else if (typeof previousValue === 'object' && previousValue.length !== undefined) { 15 | return ( 16 | 17 | previous value: 18 | {previousValue.map((v: any, i: number) => ( 19 | 20 | {v} 21 | 22 | ))} 23 | 24 | ); 25 | } else { 26 | return <>; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/ui/src/components/essential/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { ICONS } from '../../assets'; 2 | 3 | export default function Spinner() { 4 | return
{ICONS.spinner}
; 5 | } 6 | -------------------------------------------------------------------------------- /packages/ui/src/components/essential/text-input.tsx: -------------------------------------------------------------------------------- 1 | export default function TextInput(props: { 2 | value: string; 3 | setValue(v: string): void; 4 | disabled?: boolean; 5 | postfix?: string | undefined; 6 | className?: string; 7 | }) { 8 | return ( 9 |
14 | props.setValue(e.target.value)} 18 | className={ 19 | 'shadow-sm rounded-lg border-slate-300 text-sm w-full ' + 20 | 'focus:ring-blue-100 focus:border-blue-300 focus:ring ' + 21 | 'flex-grow h-9 ' + 22 | (props.disabled ? 'cursor-not-allowed bg-gray-100 ' : ' ') + 23 | (props.className ? props.className : '') 24 | } 25 | disabled={props.disabled} 26 | /> 27 | {props.postfix !== undefined && ( 28 |
33 | {props.value} {props.postfix} 34 |
35 | )} 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /packages/ui/src/components/essential/toggle.tsx: -------------------------------------------------------------------------------- 1 | export default function Toggle(props: { 2 | value: string; 3 | setValue(v: string): void; 4 | values: string[]; 5 | className?: string; 6 | }) { 7 | const { value, setValue, values, className } = props; 8 | 9 | return ( 10 |
11 |
12 | {values.map((v) => ( 13 | 28 | ))} 29 |
30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /packages/ui/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as configurationComponents } from './configuration'; 2 | export { default as essentialComponents } from './essential'; 3 | export { default as overviewComponents } from './overview'; 4 | export { default as structuralComponents } from './structural'; 5 | -------------------------------------------------------------------------------- /packages/ui/src/components/overview/index.ts: -------------------------------------------------------------------------------- 1 | import ActivityPlot from './activity-plot'; 2 | import MeasurementDecision from './measurement-decision'; 3 | import PyraCoreStatus from './pyra-core-status'; 4 | import { SystemState, TumEnclosureState } from './system-state'; 5 | 6 | export default { 7 | ActivityPlot, 8 | PyraCoreStatus, 9 | MeasurementDecision, 10 | SystemState, 11 | TumEnclosureState, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/ui/src/components/structural/disconnected-screen.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { fetchUtils } from '../../utils'; 3 | import { Button } from '../ui/button'; 4 | 5 | const LinkToInstallationInstructions = (props: { projectDirPath: string }) => ( 6 | <> 7 | Please follow the installation instructions on{' '} 8 | https://github.com/tum-esm/pyra. 9 | PYRA 4 should be located at "{props.projectDirPath}" 10 | 11 | ); 12 | 13 | export default function DisconnectedScreen(props: { retry: () => void }) { 14 | const [projectDirPath, setProjectDirPath] = useState(''); 15 | 16 | useEffect(() => { 17 | async function init() { 18 | setProjectDirPath(await fetchUtils.getProjectDirPath()); 19 | } 20 | 21 | init(); 22 | }, []); 23 | 24 | return ( 25 |
26 |
27 | PYRA has not been found on your system.{' '} 28 | 29 |
30 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /packages/ui/src/components/structural/index.ts: -------------------------------------------------------------------------------- 1 | import { Header, BlankHeader } from './header'; 2 | import DisconnectedScreen from './disconnected-screen'; 3 | 4 | export default { 5 | Header, 6 | BlankHeader, 7 | DisconnectedScreen, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/ui/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | import { cn } from '../../lib/utils'; 4 | 5 | const badgeVariants = cva( 6 | 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 7 | { 8 | variants: { 9 | variant: { 10 | default: 11 | 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', 12 | secondary: 13 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 14 | destructive: 15 | 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', 16 | outline: 'text-foreground', 17 | }, 18 | }, 19 | defaultVariants: { 20 | variant: 'default', 21 | }, 22 | } 23 | ); 24 | 25 | export interface BadgeProps 26 | extends React.HTMLAttributes, 27 | VariantProps {} 28 | 29 | function Badge({ className, variant, ...props }: BadgeProps) { 30 | return
; 31 | } 32 | 33 | export { Badge, badgeVariants }; 34 | -------------------------------------------------------------------------------- /packages/ui/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | import { cva, type VariantProps } from 'class-variance-authority'; 4 | 5 | import { cn } from '../../lib/utils'; 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', 13 | destructive: 14 | 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 15 | outline: 16 | 'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground', 17 | secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 18 | ghost: 'hover:bg-accent hover:text-accent-foreground', 19 | link: 'text-primary underline-offset-4 hover:underline', 20 | }, 21 | size: { 22 | default: 'h-9 px-4 py-2', 23 | sm: 'h-8 rounded-md px-3 text-xs', 24 | lg: 'h-10 rounded-md px-8', 25 | icon: 'h-9 w-9', 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: 'default', 30 | size: 'default', 31 | }, 32 | } 33 | ); 34 | 35 | export interface ButtonProps 36 | extends React.ButtonHTMLAttributes, 37 | VariantProps { 38 | asChild?: boolean; 39 | } 40 | 41 | const Button = React.forwardRef( 42 | ({ className, variant, size, asChild = false, ...props }, ref) => { 43 | const Comp = asChild ? Slot : 'button'; 44 | return ( 45 | 50 | ); 51 | } 52 | ); 53 | Button.displayName = 'Button'; 54 | 55 | export { Button, buttonVariants }; 56 | -------------------------------------------------------------------------------- /packages/ui/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; 3 | import { cn } from '../../lib/utils'; 4 | import { CheckIcon } from '@radix-ui/react-icons'; 5 | 6 | const Checkbox = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 21 | 22 | 23 | 24 | )); 25 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 26 | 27 | export { Checkbox }; 28 | -------------------------------------------------------------------------------- /packages/ui/src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 3 | 4 | import { cn } from "../../lib/utils" 5 | 6 | const Separator = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >( 10 | ( 11 | { className, orientation = "horizontal", decorative = true, ...props }, 12 | ref 13 | ) => ( 14 | 25 | ) 26 | ) 27 | Separator.displayName = SeparatorPrimitive.Root.displayName 28 | 29 | export { Separator } 30 | -------------------------------------------------------------------------------- /packages/ui/src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | import './styles/index.css'; 3 | import Main from './main'; 4 | 5 | // @ts-ignore 6 | const root = ReactDOM.createRoot(document.getElementById('root')); 7 | root.render(
); 8 | -------------------------------------------------------------------------------- /packages/ui/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /packages/ui/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { ICONS } from './assets'; 3 | import { structuralComponents } from './components'; 4 | import Dashboard from './components/structural/dashboard'; 5 | import toast, { Toaster } from 'react-hot-toast'; 6 | import backend from './utils/fetch-utils/backend'; 7 | 8 | export default function Main() { 9 | const [backendIntegrity, setBackendIntegrity] = useState< 10 | undefined | 'valid' | 'cli is missing' 11 | >(undefined); 12 | 13 | useEffect(() => { 14 | load(); 15 | }, []); 16 | 17 | function load() { 18 | if (backendIntegrity !== 'valid') { 19 | toast.promise(backend.pyraCliIsAvailable(), { 20 | loading: 'Connecting to Pyra backend', 21 | success: () => { 22 | setBackendIntegrity('valid'); 23 | return 'Found Pyra on your system'; 24 | }, 25 | error: () => { 26 | setBackendIntegrity('cli is missing'); 27 | return 'Could not find Pyra on your system'; 28 | }, 29 | }); 30 | } 31 | } 32 | 33 | return ( 34 |
35 | {backendIntegrity === undefined && ( 36 | <> 37 | 38 |
39 |
{ICONS.spinner}
40 |
41 | 42 | )} 43 | {backendIntegrity === 'cli is missing' && ( 44 | 45 | )} 46 | {backendIntegrity === 'valid' && } 47 | 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /packages/ui/src/styles/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .svg-green-button path { 6 | @apply fill-slate-800; 7 | } 8 | 9 | .svg-toggle-true .primary { 10 | @apply fill-blue-300; 11 | } 12 | 13 | .svg-toggle-true .secondary { 14 | @apply fill-blue-900; 15 | } 16 | 17 | .svg-toggle-false .primary { 18 | @apply fill-gray-200; 19 | } 20 | 21 | .svg-toggle-false .secondary { 22 | @apply fill-transparent; 23 | } 24 | 25 | .flex-row-center { 26 | @apply flex flex-row items-center justify-center; 27 | } 28 | 29 | .flex-col-center { 30 | @apply flex flex-col items-center justify-center; 31 | } 32 | 33 | .flex-col-left { 34 | @apply flex flex-col items-start justify-center; 35 | } 36 | 37 | .flex-col-right { 38 | @apply flex flex-col items-end justify-center; 39 | } 40 | 41 | .flex-row-left { 42 | @apply flex flex-row items-center justify-start; 43 | } 44 | 45 | .flex-row-right { 46 | @apply flex flex-row items-center justify-end; 47 | } 48 | 49 | .flex-row-left-top { 50 | @apply flex flex-row items-start justify-start; 51 | } 52 | 53 | .elevated-panel { 54 | @apply shadow-sm rounded-lg bg-white border-gray-250 border; 55 | } 56 | 57 | @layer base { 58 | :root { 59 | --background: 0 0% 100%; 60 | --foreground: 222.2 84% 4.9%; 61 | 62 | --card: 0 0% 100%; 63 | --card-foreground: 222.2 84% 4.9%; 64 | 65 | --popover: 0 0% 100%; 66 | --popover-foreground: 222.2 84% 4.9%; 67 | 68 | --primary: 222.2 47.4% 11.2%; 69 | --primary-foreground: 210 40% 98%; 70 | 71 | --secondary: 210 40% 96.1%; 72 | --secondary-foreground: 222.2 47.4% 11.2%; 73 | 74 | --muted: 210 40% 96.1%; 75 | --muted-foreground: 215.4 16.3% 46.9%; 76 | 77 | --accent: 210 40% 96.1%; 78 | --accent-foreground: 222.2 47.4% 11.2%; 79 | 80 | --destructive: 0 84.2% 60.2%; 81 | --destructive-foreground: 210 40% 98%; 82 | 83 | --border: 214.3 31.8% 91.4%; 84 | --input: 214.3 31.8% 91.4%; 85 | --ring: 222.2 84% 4.9%; 86 | 87 | --radius: 0.5rem; 88 | } 89 | 90 | .dark { 91 | --background: 222.2 84% 4.9%; 92 | --foreground: 210 40% 98%; 93 | 94 | --card: 222.2 84% 4.9%; 95 | --card-foreground: 210 40% 98%; 96 | 97 | --popover: 222.2 84% 4.9%; 98 | --popover-foreground: 210 40% 98%; 99 | 100 | --primary: 210 40% 98%; 101 | --primary-foreground: 222.2 47.4% 11.2%; 102 | 103 | --secondary: 217.2 32.6% 17.5%; 104 | --secondary-foreground: 210 40% 98%; 105 | 106 | --muted: 217.2 32.6% 17.5%; 107 | --muted-foreground: 215 20.2% 65.1%; 108 | 109 | --accent: 217.2 32.6% 17.5%; 110 | --accent-foreground: 210 40% 98%; 111 | 112 | --destructive: 0 62.8% 30.6%; 113 | --destructive-foreground: 210 40% 98%; 114 | 115 | --border: 217.2 32.6% 17.5%; 116 | --input: 217.2 32.6% 17.5%; 117 | --ring: 212.7 26.8% 83.9%; 118 | } 119 | } 120 | 121 | @layer base { 122 | * { 123 | @apply border-border; 124 | } 125 | body { 126 | @apply bg-background text-foreground; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /packages/ui/src/tabs/index.ts: -------------------------------------------------------------------------------- 1 | export { default as OverviewTab } from './overview-tab'; 2 | export { default as ConfigurationTab } from './configuration-tab'; 3 | export { default as LogTab } from './log-tab'; 4 | export { default as ControlTab } from './control-tab'; 5 | -------------------------------------------------------------------------------- /packages/ui/src/tabs/overview-tab.tsx: -------------------------------------------------------------------------------- 1 | import { fetchUtils } from '../utils'; 2 | import { essentialComponents, overviewComponents } from '../components'; 3 | import { useLogsStore } from '../utils/zustand-utils/logs-zustand'; 4 | import { useConfigStore } from '../utils/zustand-utils/config-zustand'; 5 | import { Button } from '../components/ui/button'; 6 | 7 | export default function OverviewTab() { 8 | const { coreLogs } = useLogsStore(); 9 | const { runPromisingCommand } = fetchUtils.useCommand(); 10 | const { centralConfig } = useConfigStore(); 11 | 12 | function closeCover() { 13 | runPromisingCommand({ 14 | command: () => fetchUtils.backend.writeToTUMEnclosure(['close-cover']), 15 | label: 'closing cover', 16 | successLabel: 'successfully closed cover', 17 | }); 18 | } 19 | 20 | return ( 21 |
22 |
23 | 24 | 25 |
26 |
27 | 28 |
29 |
30 |
System State
31 |
32 | {centralConfig?.tum_enclosure && ( 33 | 36 | )} 37 |
38 | 39 |
40 | Recent Logs 41 |
42 |
43 | {(coreLogs.all === undefined || coreLogs.all.length === 0) && ( 44 |
45 | 46 |
47 | )} 48 | {coreLogs.all !== undefined && 49 | coreLogs.all 50 | .filter((l) => !l.includes(' - DEBUG - ')) 51 | .slice(-15) 52 | .map((l, i) => ( 53 | 58 | ))} 59 |
60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /packages/ui/src/utils/fetch-utils/backend.ts: -------------------------------------------------------------------------------- 1 | import { Command, ChildProcess } from '@tauri-apps/plugin-shell'; 2 | import { join } from '@tauri-apps/api/path'; 3 | import fetchUtils from '.'; 4 | 5 | async function callCLI(args: string[]): Promise> { 6 | let projectDirPath = await fetchUtils.getProjectDirPath(); 7 | let pythonInterpreter = 8 | import.meta.env.VITE_PYTHON_INTERPRETER === 'venv' ? 'venv-python' : 'system-python'; 9 | let pyraCLIEntrypoint = await join('packages', 'cli', 'main.py'); 10 | 11 | const commandString = [pythonInterpreter, pyraCLIEntrypoint, ...args].join(' '); 12 | console.debug(`Running shell command: "${commandString}" in directory "${projectDirPath}"`); 13 | 14 | return new Promise(async (resolve, reject) => { 15 | Command.create(pythonInterpreter, [pyraCLIEntrypoint, ...args], { 16 | cwd: projectDirPath, 17 | }) 18 | .execute() 19 | .then((result) => { 20 | if (result.code === 0) { 21 | console.debug('CLI command executed successfully'); 22 | resolve(result); 23 | } else { 24 | console.error('Error when calling CLI: ', result); 25 | reject(result); 26 | } 27 | }) 28 | .catch((error) => { 29 | console.error('Error when calling CLI: ', error); 30 | reject(error); 31 | }); 32 | }); 33 | } 34 | 35 | const backend = { 36 | pyraCliIsAvailable: async (): Promise> => { 37 | return await callCLI([]); 38 | }, 39 | checkPyraCoreState: async (): Promise> => { 40 | return await callCLI(['core', 'is-running']); 41 | }, 42 | startPyraCore: async (): Promise> => { 43 | return await callCLI(['core', 'start']); 44 | }, 45 | stopPyraCore: async (): Promise> => { 46 | return await callCLI(['core', 'stop']); 47 | }, 48 | getConfig: async (): Promise> => { 49 | return await callCLI(['config', 'get', '--no-indent', '--no-color']); 50 | }, 51 | updateConfig: async (newConfig: any): Promise> => { 52 | return await callCLI(['config', 'update', JSON.stringify(newConfig)]); 53 | }, 54 | writeToTUMEnclosure: async (command: string[]): Promise> => { 55 | return await callCLI(['tum-enclosure', ...command]); 56 | }, 57 | testOpus: async (): Promise> => { 58 | return await callCLI(['test', 'opus']); 59 | }, 60 | testCamTracker: async (): Promise> => { 61 | return await callCLI(['test', 'camtracker']); 62 | }, 63 | testEmail: async (): Promise> => { 64 | return await callCLI(['test', 'email']); 65 | }, 66 | testUpload: async (): Promise> => { 67 | return await callCLI(['test', 'upload']); 68 | }, 69 | }; 70 | 71 | export default backend; 72 | -------------------------------------------------------------------------------- /packages/ui/src/utils/fetch-utils/get-file-content.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from '@tauri-apps/plugin-fs'; 2 | import { BaseDirectory, join } from '@tauri-apps/api/path'; 3 | import { split, tail } from 'lodash'; 4 | 5 | async function getFileContent(filePath: string): Promise { 6 | let absoluteFilePath: string; 7 | 8 | if (import.meta.env.VITE_PYRA_DIRECTORY !== undefined) { 9 | absoluteFilePath = await join( 10 | ...tail(split(import.meta.env.VITE_PYRA_DIRECTORY, '/Documents/')), 11 | ...filePath.split('/') 12 | ); 13 | } else { 14 | absoluteFilePath = await join('pyra', `pyra-${APP_VERSION}`, ...filePath.split('/')); 15 | } 16 | 17 | console.debug(`Reading file: "${absoluteFilePath}" in ~/Documents`); 18 | return new TextDecoder('utf-8').decode( 19 | await readFile(absoluteFilePath, { baseDir: BaseDirectory.Document }) 20 | ); 21 | } 22 | 23 | export default getFileContent; 24 | -------------------------------------------------------------------------------- /packages/ui/src/utils/fetch-utils/get-project-dir-path.ts: -------------------------------------------------------------------------------- 1 | import { documentDir, join } from '@tauri-apps/api/path'; 2 | import { split, tail } from 'lodash'; 3 | 4 | async function getProjectDirPath() { 5 | if (import.meta.env.VITE_PYRA_DIRECTORY !== undefined) { 6 | return await join( 7 | await documentDir(), 8 | ...tail(split(import.meta.env.VITE_PYRA_DIRECTORY, '/Documents/')) 9 | ); 10 | } else { 11 | return await join(await documentDir(), 'pyra', `pyra-${APP_VERSION}`); 12 | } 13 | } 14 | 15 | export default getProjectDirPath; 16 | -------------------------------------------------------------------------------- /packages/ui/src/utils/fetch-utils/index.ts: -------------------------------------------------------------------------------- 1 | import backend from './backend'; 2 | import getFileContent from './get-file-content'; 3 | import getProjectDirPath from './get-project-dir-path'; 4 | import useCommand from './use-command'; 5 | 6 | export default { 7 | backend, 8 | getFileContent, 9 | getProjectDirPath, 10 | useCommand, 11 | }; 12 | -------------------------------------------------------------------------------- /packages/ui/src/utils/fetch-utils/use-command.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess } from '@tauri-apps/plugin-shell'; 2 | import { useLogsStore } from '../zustand-utils/logs-zustand'; 3 | import toast from 'react-hot-toast'; 4 | import { useState } from 'react'; 5 | 6 | export default function useCommand() { 7 | const { addUiLogLine } = useLogsStore(); 8 | const [commandIsRunning, setCommandIsRunning] = useState(false); 9 | 10 | function exitFromFailedProcess(p: ChildProcess, label: string): string { 11 | addUiLogLine( 12 | `Error while ${label}.`, 13 | `stdout: "${p.stdout.trim()}"\nstderr: "${p.stderr.trim()}"` 14 | ); 15 | return `Error while ${label}, full error in UI logs`; 16 | } 17 | 18 | function runPromisingCommand(args: { 19 | command: () => Promise>; 20 | label: string; 21 | successLabel: string; 22 | onSuccess?: (p: ChildProcess) => void; 23 | onError?: (p: ChildProcess) => void; 24 | }): void { 25 | if (commandIsRunning) { 26 | toast.error('Cannot run multiple commands at the same time'); 27 | } else { 28 | setCommandIsRunning(true); 29 | toast.promise(args.command(), { 30 | loading: args.label, 31 | success: (p: ChildProcess) => { 32 | if (args.onSuccess) { 33 | args.onSuccess(p); 34 | } 35 | setCommandIsRunning(false); 36 | return args.successLabel; 37 | }, 38 | error: (p: ChildProcess) => { 39 | if (args.onError) { 40 | args.onError(p); 41 | } 42 | setCommandIsRunning(false); 43 | return exitFromFailedProcess(p, args.label); 44 | }, 45 | }); 46 | } 47 | } 48 | 49 | return { 50 | commandIsRunning, 51 | runPromisingCommand, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /packages/ui/src/utils/functions.ts: -------------------------------------------------------------------------------- 1 | export function renderString( 2 | value: undefined | null | string | number, 3 | options?: { appendix: string } 4 | ) { 5 | if (value === undefined || value === null) { 6 | return '-'; 7 | } else { 8 | return `${value}${options !== undefined ? options.appendix : ''}`; 9 | } 10 | } 11 | 12 | export function renderBoolean(value: undefined | null | boolean) { 13 | if (value === undefined || value === null) { 14 | return '-'; 15 | } else { 16 | return value ? 'Yes' : 'No'; 17 | } 18 | } 19 | 20 | export function renderNumber( 21 | value: undefined | null | string | number, 22 | options?: { appendix: string } 23 | ) { 24 | if (value === undefined || value === null) { 25 | return '-'; 26 | } else { 27 | return `${Number(value).toFixed(2)}${options !== undefined ? options.appendix : ''}`; 28 | } 29 | } 30 | 31 | export function renderTimeObject(value: { hour: number; minute: number; second: number }) { 32 | const hourString = `${value.hour}`.padStart(2, '0'); 33 | const minuteString = `${value.minute}`.padStart(2, '0'); 34 | const secondString = `${value.second}`.padStart(2, '0'); 35 | return `${hourString}:${minuteString}:${secondString}`; 36 | } 37 | -------------------------------------------------------------------------------- /packages/ui/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as fetchUtils } from './fetch-utils'; 2 | -------------------------------------------------------------------------------- /packages/ui/src/utils/zustand-utils/activity-zustand.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { z } from 'zod'; 3 | 4 | export const MINUTES_PER_BIN = 5; 5 | 6 | const activityHistorySchema = z.object({ 7 | is_running: z.array(z.number()).length(24 * 60), 8 | is_measuring: z.array(z.number()).length(24 * 60), 9 | has_errors: z.array(z.number()).length(24 * 60), 10 | camtracker_startups: z.array(z.number()).length(24 * 60), 11 | opus_startups: z.array(z.number()).length(24 * 60), 12 | cli_calls: z.array(z.number()).length(24 * 60), 13 | is_uploading: z.array(z.number()).length(24 * 60), 14 | }); 15 | export type ActivityHistory = z.infer; 16 | 17 | export type ActivitySection = { 18 | from_minute_index: number; 19 | to_minute_index: number; 20 | count: number; 21 | }; 22 | 23 | function parseActivityHistoryTimeSeries(ts: number[]): ActivitySection[] { 24 | const smallerTs: number[] = []; 25 | for (let i = 0; i < ts.length; i++) { 26 | if (i % MINUTES_PER_BIN === 0) { 27 | smallerTs.push(ts[i]); 28 | } else { 29 | smallerTs[smallerTs.length - 1] += ts[i]; 30 | } 31 | } 32 | const sections: ActivitySection[] = []; 33 | let currentSection: ActivitySection | undefined = undefined; 34 | for (let i = 0; i < smallerTs.length; i++) { 35 | if (smallerTs[i] > 0) { 36 | if (!currentSection) { 37 | currentSection = { 38 | from_minute_index: i * MINUTES_PER_BIN, 39 | to_minute_index: i * MINUTES_PER_BIN, 40 | count: smallerTs[i], 41 | }; 42 | } else { 43 | currentSection.to_minute_index = i * MINUTES_PER_BIN; 44 | currentSection.count += smallerTs[i]; 45 | } 46 | } else { 47 | if (currentSection) { 48 | sections.push(currentSection); 49 | currentSection = undefined; 50 | } 51 | } 52 | } 53 | if (currentSection) { 54 | sections.push(currentSection); 55 | } 56 | return sections; 57 | } 58 | 59 | interface ActivityHistoryStore { 60 | activitySections: { 61 | is_running: ActivitySection[]; 62 | is_measuring: ActivitySection[]; 63 | has_errors: ActivitySection[]; 64 | camtracker_startups: ActivitySection[]; 65 | opus_startups: ActivitySection[]; 66 | cli_calls: ActivitySection[]; 67 | is_uploading: ActivitySection[]; 68 | }; 69 | 70 | setActivityHistory: (ah: any) => void; 71 | } 72 | 73 | export const useActivityHistoryStore = create()((set) => ({ 74 | activitySections: { 75 | is_running: [], 76 | is_measuring: [], 77 | has_errors: [], 78 | camtracker_startups: [], 79 | opus_startups: [], 80 | cli_calls: [], 81 | is_uploading: [], 82 | }, 83 | setActivityHistory: (ah: string) => { 84 | const parsed = activityHistorySchema.parse(ah); 85 | set(() => ({ 86 | activitySections: { 87 | is_running: parseActivityHistoryTimeSeries(parsed.is_running), 88 | is_measuring: parseActivityHistoryTimeSeries(parsed.is_measuring), 89 | has_errors: parseActivityHistoryTimeSeries(parsed.has_errors), 90 | camtracker_startups: parseActivityHistoryTimeSeries(parsed.camtracker_startups), 91 | opus_startups: parseActivityHistoryTimeSeries(parsed.opus_startups), 92 | cli_calls: parseActivityHistoryTimeSeries(parsed.cli_calls), 93 | is_uploading: parseActivityHistoryTimeSeries(parsed.is_uploading), 94 | }, 95 | })); 96 | }, 97 | })); 98 | -------------------------------------------------------------------------------- /packages/ui/src/utils/zustand-utils/core-process-zustand.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface CoreProcessStore { 4 | pyraCorePid: number | undefined; 5 | setPyraCorePid: (pid: number | undefined) => void; 6 | } 7 | 8 | export const useCoreProcessStore = create()((set) => ({ 9 | pyraCorePid: undefined, 10 | setPyraCorePid: (pid) => set(() => ({ pyraCorePid: pid })), 11 | })); 12 | -------------------------------------------------------------------------------- /packages/ui/src/utils/zustand-utils/core-state-zustand.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { z } from 'zod'; 3 | import { set as lodashSet } from 'lodash'; 4 | 5 | const coreStateSchema = z.object({ 6 | position: z.object({ 7 | latitude: z.number().nullable(), 8 | longitude: z.number().nullable(), 9 | altitude: z.number().nullable(), 10 | sun_elevation: z.number().nullable(), 11 | }), 12 | measurements_should_be_running: z.boolean().nullable(), 13 | tum_enclosure_state: z.object({ 14 | last_full_fetch: z.string().nullable(), 15 | actors: z.object({ 16 | fan_speed: z.number().nullable(), 17 | current_angle: z.number().nullable(), 18 | }), 19 | control: z.object({ 20 | auto_temp_mode: z.boolean().nullable(), 21 | manual_control: z.boolean().nullable(), 22 | manual_temp_mode: z.boolean().nullable(), 23 | sync_to_tracker: z.boolean().nullable(), 24 | }), 25 | sensors: z.object({ 26 | humidity: z.number().nullable(), 27 | temperature: z.number().nullable(), 28 | }), 29 | state: z.object({ 30 | cover_closed: z.boolean().nullable(), 31 | motor_failed: z.boolean().nullable(), 32 | rain: z.boolean().nullable(), 33 | reset_needed: z.boolean().nullable(), 34 | ups_alert: z.boolean().nullable(), 35 | }), 36 | power: z.object({ 37 | camera: z.boolean().nullable(), 38 | computer: z.boolean().nullable(), 39 | heater: z.boolean().nullable(), 40 | router: z.boolean().nullable(), 41 | spectrometer: z.boolean().nullable(), 42 | }), 43 | connections: z.object({ 44 | camera: z.boolean().nullable(), 45 | computer: z.boolean().nullable(), 46 | heater: z.boolean().nullable(), 47 | router: z.boolean().nullable(), 48 | spectrometer: z.boolean().nullable(), 49 | }), 50 | }), 51 | operating_system_state: z.object({ 52 | cpu_usage: z.array(z.number()).nullable(), 53 | memory_usage: z.number().nullable(), 54 | last_boot_time: z.string().nullable(), 55 | filled_disk_space_fraction: z.number().nullable(), 56 | }), 57 | }); 58 | 59 | export type CoreState = z.infer; 60 | 61 | interface CoreStateStore { 62 | coreState: CoreState | undefined; 63 | setCoreState: (s: any) => void; 64 | setCoreStateItem: (path: string, value: any) => void; 65 | } 66 | 67 | export const useCoreStateStore = create()((set) => ({ 68 | coreState: undefined, 69 | setCoreState: (s: any) => set(() => ({ coreState: coreStateSchema.parse(s) })), 70 | setCoreStateItem: (path: string, value: any) => 71 | set((state) => { 72 | if (state.coreState === undefined) { 73 | return {}; 74 | } 75 | return { 76 | coreState: lodashSet(state.coreState, path, value), 77 | }; 78 | }), 79 | })); 80 | -------------------------------------------------------------------------------- /packages/ui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare const APP_VERSION: string; 4 | -------------------------------------------------------------------------------- /packages/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /packages/ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.mjs"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/ui/vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | define: { 8 | APP_VERSION: JSON.stringify(process.env.npm_package_version), 9 | }, 10 | build: { 11 | chunkSizeWarningLimit: 1024, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pyra" 3 | version = "4.2.2" 4 | description = "Automated EM27/SUN Greenhouse Gas Measurements" 5 | authors = [ 6 | { name = "Moritz Makowski", email = "moritz.makowski@tum.de" }, 7 | { name = "Patrick Aigner", email = "patrick.aigner@tum.de" }, 8 | { name = "Andreas Luther", email = "andreas.luther@tum.de" }, 9 | { name = "Friedrich Klappenbach", email = "friedrich.klappenbach@tum.de" }, 10 | ] 11 | dependencies = [ 12 | "numpy>=2.2.5", 13 | "click==8.1.8", 14 | "python-snap7==2.0.2", 15 | "jdcal==1.4.1", 16 | "tqdm>=4.67.1", 17 | "colorama>=0.4.6", 18 | "deepdiff>=8.4.2", 19 | "skyfield>=1.53", 20 | "circadian-scp-upload>=0.5.2", 21 | "setuptools>=79.0.0", 22 | "pydantic>=2.11.3", 23 | "filelock>=3.18.0", 24 | "psutil>=7.0.0", 25 | "scikit-image>=0.25.2", 26 | "pillow>=11.2.1", 27 | "requests>=2.32.3", 28 | "opencv-python>=4.11.0.86", 29 | "tum-esm-utils[opus]>=2.7.2", 30 | ] 31 | requires-python = "==3.10.*" 32 | readme = "README.md" 33 | license = { text = "GPL-3" } 34 | 35 | [project.optional-dependencies] 36 | dev = [ 37 | "lazydocs>=0.4.8", 38 | "mypy>=1.8.0", 39 | "pytest>=7.4.4", 40 | "pytest-cov>=4.1.0", 41 | "pytest-order>=1.2.1", 42 | "ruff>=0.8.2", 43 | "types-psutil>=5.9.5.20240106", 44 | "types-paramiko>=3.4.0.20240106", 45 | "types-invoke>=2.0.0.10", 46 | "types-requests>=2.32.0.20241016", 47 | ] 48 | 49 | [tool.ruff] 50 | line-length = 100 51 | 52 | [tool.ruff.lint] 53 | ignore = ["E402", "E741"] 54 | exclude = ["tests/*"] 55 | 56 | [tool.mypy] 57 | strict = true 58 | implicit_reexport = true 59 | warn_unused_ignores = false 60 | untyped_calls_exclude = ["skimage"] 61 | plugins = ["pydantic.mypy"] 62 | 63 | [[tool.mypy.overrides]] 64 | module = [ 65 | "cv2", 66 | "jdcal", 67 | "deepdiff", 68 | "psutil", 69 | "skyfield.*", 70 | "fabric.*", 71 | "brukeropus", 72 | "brukeropus.*", 73 | "skimage.*", 74 | ] 75 | ignore_missing_imports = true 76 | 77 | [tool.pytest.ini_options] 78 | filterwarnings = [ 79 | 'ignore:the imp module is deprecated in favour of importlib:DeprecationWarning', 80 | 'ignore:pkg_resources is deprecated as an API:DeprecationWarning', 81 | 'ignore:getargs:DeprecationWarning', 82 | ] 83 | markers = [ 84 | "ci: can be run in a CI environment", 85 | "integration: can only be run on a configured system", 86 | ] 87 | 88 | [tool.pdm.options] 89 | add = ["--no-self"] 90 | install = ["--no-self"] 91 | remove = ["--no-self"] 92 | 93 | [tool.pdm] 94 | distribution = false 95 | 96 | [tool.setuptools] 97 | packages = [] 98 | -------------------------------------------------------------------------------- /run_pyra_core.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import filelock 4 | import tum_esm_utils 5 | 6 | from packages.core import main 7 | 8 | _run_pyra_core_lock = filelock.FileLock( 9 | os.path.join( 10 | tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=2), 11 | "run_pyra_core.lock", 12 | ), 13 | timeout=0.5, 14 | ) 15 | 16 | if __name__ == "__main__": 17 | try: 18 | _run_pyra_core_lock.acquire() 19 | try: 20 | main.run() 21 | finally: 22 | _run_pyra_core_lock.release() 23 | except filelock.Timeout: 24 | print("Aborting start: core process is already running") 25 | -------------------------------------------------------------------------------- /scripts/run_headless_helios_thread.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import tum_esm_utils 3 | 4 | sys.path.append(tum_esm_utils.files.rel_to_abs_path("..")) 5 | 6 | from packages.core import threads 7 | 8 | if __name__ == "__main__": 9 | threads.HeliosThread.main(headless=True) 10 | -------------------------------------------------------------------------------- /scripts/run_headless_upload_thread.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import tum_esm_utils 3 | 4 | sys.path.append(tum_esm_utils.files.rel_to_abs_path("..")) 5 | 6 | from packages.core import threads 7 | 8 | if __name__ == "__main__": 9 | threads.UploadThread.main(headless=True) 10 | -------------------------------------------------------------------------------- /scripts/sync_version_numbers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | dir = os.path.dirname 5 | PROJECT_DIR = dir(dir(os.path.abspath(__file__))) 6 | 7 | TOML_PATH = os.path.join(PROJECT_DIR, "pyproject.toml") 8 | PACKAGE_JSON_PATH = os.path.join(PROJECT_DIR, "packages", "ui", "package.json") 9 | TAURI_JSON_PATH = os.path.join(PROJECT_DIR, "packages", "ui", "src-tauri", "tauri.conf.json") 10 | 11 | # load version number 12 | with open(TOML_PATH, "r") as f: 13 | while True: 14 | line = f.readline() 15 | if not line.startswith("version"): 16 | continue 17 | version = line.split("=")[1].replace('"', "").replace(" ", "").replace("\n", "") 18 | break 19 | 20 | # update package.json 21 | with open(PACKAGE_JSON_PATH, "r") as f: 22 | json_content = json.load(f) 23 | json_content["version"] = version 24 | with open(PACKAGE_JSON_PATH, "w") as f: 25 | json.dump(json_content, f, indent=4) 26 | 27 | # update tauri.conf.json 28 | with open(TAURI_JSON_PATH, "r") as f: 29 | json_content = json.load(f) 30 | json_content["package"]["version"] = version 31 | with open(TAURI_JSON_PATH, "w") as f: 32 | json.dump(json_content, f, indent=4) 33 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/tests/__init__.py -------------------------------------------------------------------------------- /tests/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/tests/cli/__init__.py -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_camtracker_connection.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from packages.core import types, threads, utils 3 | 4 | 5 | @pytest.mark.order(3) 6 | @pytest.mark.integration 7 | def test_camtracker_connection() -> None: 8 | config = types.Config.load() 9 | logger = utils.Logger(origin="camtracker", just_print=True) 10 | threads.camtracker_thread.CamTrackerThread.test_setup(config, logger) 11 | -------------------------------------------------------------------------------- /tests/integration/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | import tum_esm_utils 5 | 6 | PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=3) 7 | CONFIG_DIR = os.path.join(PROJECT_DIR, "config") 8 | 9 | sys.path.append(PROJECT_DIR) 10 | from packages.core import types 11 | 12 | 13 | @pytest.mark.order(1) 14 | @pytest.mark.integration 15 | def test_config() -> None: 16 | types.Config.load() 17 | -------------------------------------------------------------------------------- /tests/integration/test_emailing.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from packages.core import utils, types 3 | 4 | 5 | @pytest.mark.order(2) 6 | @pytest.mark.integration 7 | def test_emailing() -> None: 8 | config = types.Config.load() 9 | utils.ExceptionEmailClient.send_test_email(config) 10 | -------------------------------------------------------------------------------- /tests/integration/test_opus_connection.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from packages.core import types, threads, utils 3 | 4 | 5 | @pytest.mark.order(3) 6 | @pytest.mark.integration 7 | def test_opus_connection() -> None: 8 | config = types.Config.load() 9 | logger = utils.Logger(origin="opus", just_print=True) 10 | try: 11 | threads.OpusThread.test_setup(config, logger) 12 | finally: 13 | threads.opus_thread.OpusProgram.stop(logger) 14 | -------------------------------------------------------------------------------- /tests/integration/test_uploading.py: -------------------------------------------------------------------------------- 1 | import circadian_scp_upload 2 | import pytest 3 | from packages.core import types 4 | 5 | 6 | @pytest.mark.order(2) 7 | @pytest.mark.integration 8 | def test_uploading() -> None: 9 | config = types.Config.load() 10 | if config.upload is None: 11 | return 12 | 13 | with circadian_scp_upload.RemoteConnection( 14 | config.upload.host.root, 15 | config.upload.user, 16 | config.upload.password, 17 | ) as remote_connection: 18 | if remote_connection.connection.is_connected: 19 | return 20 | 21 | raise Exception("`circadian_scp_upload` should have raised an error") 22 | -------------------------------------------------------------------------------- /tests/repository/README.md: -------------------------------------------------------------------------------- 1 | Tests for the repositories state. 2 | 3 | 1. Are the default configs in a valid state (all validation rules except paths) 4 | 2. Is the static type analysis running through? 5 | 3. ... 6 | 7 | These tests can be run in a CI environment 8 | -------------------------------------------------------------------------------- /tests/repository/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/tests/repository/__init__.py -------------------------------------------------------------------------------- /tests/repository/test_default_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | import tum_esm_utils 5 | 6 | PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=3) 7 | CONFIG_DIR = os.path.join(PROJECT_DIR, "config") 8 | 9 | sys.path.append(PROJECT_DIR) 10 | from packages.core import types 11 | 12 | 13 | @pytest.mark.order(2) 14 | @pytest.mark.ci 15 | def test_default_config() -> None: 16 | with open(os.path.join(CONFIG_DIR, "config.default.json"), "r") as f: 17 | types.Config.load(f.read(), ignore_path_existence=True) 18 | 19 | with open(os.path.join(CONFIG_DIR, "tum_enclosure.config.default.json"), "r") as f: 20 | types.enclosures.tum_enclosure.TUMEnclosureConfig.model_validate_json(f.read()) 21 | 22 | with open(os.path.join(CONFIG_DIR, "helios.config.default.json"), "r") as f: 23 | types.config.HeliosConfig.model_validate_json(f.read()) 24 | 25 | with open(os.path.join(CONFIG_DIR, "upload.config.default.json"), "r") as f: 26 | types.config.UploadConfig.model_validate_json( 27 | f.read(), 28 | context={"ignore-path-existence": True}, 29 | ) 30 | -------------------------------------------------------------------------------- /tests/repository/test_static_types.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import pytest 4 | import tum_esm_utils 5 | 6 | PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=3) 7 | 8 | 9 | def _rmdir(path: str) -> None: 10 | path = os.path.join(PROJECT_DIR, path) 11 | if os.path.isdir(path): 12 | shutil.rmtree(path) 13 | 14 | 15 | def _rm(path: str) -> None: 16 | path = os.path.join(PROJECT_DIR, path) 17 | os.system(f"rm -rf {path}") 18 | 19 | 20 | @pytest.mark.order(1) 21 | @pytest.mark.ci 22 | def test_static_types() -> None: 23 | _rmdir(".mypy_cache/3.10/packages") 24 | _rmdir(".mypy_cache/3.10/tests") 25 | _rm(".mypy_cache/3.10/run_pyra_core.*") 26 | 27 | for path in [ 28 | "run_pyra_core.py", 29 | "packages/cli/main.py", 30 | "tests/", 31 | ]: 32 | assert os.system(f"cd {PROJECT_DIR} && python -m mypy {path}") == 0 33 | -------------------------------------------------------------------------------- /tests/repository/test_version_numbers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import pytest 5 | import tum_esm_utils 6 | 7 | PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=3) 8 | 9 | 10 | @pytest.mark.order(2) 11 | @pytest.mark.ci 12 | def test_version_numbers() -> None: 13 | with open(os.path.join(PROJECT_DIR, "pyproject.toml"), "r") as f: 14 | third_line = f.read().split("\n")[2] 15 | assert third_line.startswith("version = ") 16 | pyproject_version = third_line.split(" = ")[1].strip('"') 17 | 18 | cli_info_stdout = tum_esm_utils.shell.run_shell_command( 19 | f"{sys.executable} ./packages/cli/main.py info", working_directory=PROJECT_DIR 20 | ).strip(" \n") 21 | assert ( 22 | cli_info_stdout 23 | == f'This CLI is running Pyra version {pyproject_version} in directory "{PROJECT_DIR}"' 24 | ) 25 | 26 | with open(os.path.join(PROJECT_DIR, "config", "config.default.json"), "r") as f: 27 | default_config_version = json.load(f)["general"]["version"] 28 | assert default_config_version == pyproject_version 29 | 30 | with open(os.path.join(PROJECT_DIR, "packages", "ui", "package.json"), "r") as f: 31 | ui_package_version = json.load(f)["version"] 32 | assert ui_package_version == pyproject_version 33 | 34 | with open(os.path.join(PROJECT_DIR, "packages", "ui", "src-tauri", "Cargo.toml"), "r") as f: 35 | third_line = f.read().split("\n")[2] 36 | assert third_line.startswith("version = ") 37 | cargo_version = third_line.split(" = ")[1].strip('"') 38 | assert cargo_version == pyproject_version 39 | 40 | with open( 41 | os.path.join(PROJECT_DIR, "packages", "ui", "src-tauri", "tauri.conf.json"), "r" 42 | ) as f: 43 | tauri_conf_version = json.load(f)["version"] 44 | assert tauri_conf_version == pyproject_version 45 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tum-esm/pyra/d24724f1c50b66db4f482426bf16515b5adffcae/tests/utils/__init__.py -------------------------------------------------------------------------------- /tests/utils/test_astronomy.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import random 4 | import sys 5 | import tempfile 6 | import pytest 7 | import tum_esm_utils 8 | from ..fixtures import sample_config 9 | 10 | PROJECT_DIR = tum_esm_utils.files.get_parent_dir_path(__file__, current_depth=3) 11 | CONFIG_DIR = os.path.join(PROJECT_DIR, "config") 12 | 13 | sys.path.append(PROJECT_DIR) 14 | from packages.core import types, utils 15 | 16 | 17 | @pytest.mark.order(3) 18 | @pytest.mark.ci 19 | def test_astronomy(sample_config: types.Config) -> None: 20 | # disable test mode because in test mode it uses 21 | # munich coordinates instead of reading them from 22 | # the CamTracker config file 23 | sample_config.general.test_mode = False 24 | 25 | try: 26 | utils.Astronomy.get_current_sun_elevation(sample_config) 27 | raise Exception("Failed to warn about not loaded astronomy data.") 28 | except AssertionError: 29 | pass 30 | 31 | utils.Astronomy.load_astronomical_dataset() 32 | 33 | # use tmp camtracker config file with munich coordinates at $1 marker 34 | tmp_filename = tempfile.NamedTemporaryFile(delete=True).name 35 | with open(tmp_filename, "w") as f: 36 | f.write("$1\n") 37 | f.write("48.137154\n") 38 | f.write("11.576124\n") 39 | f.write("515\n") 40 | sample_config.camtracker.config_path.root = tmp_filename 41 | 42 | # test whether elevation is correctly computed using the config file 43 | e1 = utils.Astronomy.get_current_sun_elevation(sample_config) 44 | assert isinstance(e1, float) 45 | 46 | # test whether coordinates are correctly read from camtracker config file 47 | e2 = utils.Astronomy.get_current_sun_elevation( 48 | sample_config, lat=48.137154, lon=11.576124, alt=515 49 | ) 50 | assert isinstance(e2, float) 51 | assert abs(e1 - e2) < 1e-2 52 | 53 | # generate a random datetime for every year between 2020 and 2050 54 | # and test whether the elevation is correctly computed 55 | for year in range(2020, 2051): 56 | e = utils.Astronomy.get_current_sun_elevation( 57 | sample_config, 58 | datetime_object=datetime.datetime( 59 | year=year, 60 | month=random.randint(1, 12), 61 | day=random.randint(1, 28), 62 | hour=random.randint(0, 23), 63 | minute=random.randint(0, 59), 64 | second=random.randint(0, 59), 65 | ), 66 | ) 67 | assert isinstance(e, float) 68 | assert -90 <= e <= 90 69 | --------------------------------------------------------------------------------