From 02860ebaff2c33ec4f3b523e52d8b091411c5994 Mon Sep 17 00:00:00 2001 From: Mark McIntyre Date: Wed, 12 Nov 2025 11:31:33 +0000 Subject: [PATCH 1/4] moved fb collector and user mgmt to their own repos --- .github/workflows/build_tools.yml | 125 --- fbCollector/README.md | 57 - fbCollector/config.ini.sample | 32 - fbCollector/download_events.sh | 67 -- fbCollector/fbCollector.ps1 | 25 - fbCollector/fbcollector.yml | 24 - fbCollector/fireballCollector.py | 1244 --------------------- fbCollector/fireballcollector_setup.iss | 31 - fbCollector/noimage.jpg | Bin 70608 -> 0 bytes fbCollector/requirements.txt | 10 - fbCollector/ukmda.ico | Bin 16958 -> 0 bytes fbCollector/vars.yml | 2 - usermgmt/README.md | 68 -- usermgmt/windows/build.ps1 | 1 - usermgmt/windows/cam_management_setup.iss | 29 - usermgmt/windows/camera.ico | Bin 16958 -> 0 bytes usermgmt/windows/release_notes.md | 19 - usermgmt/windows/requirements.txt | 8 - usermgmt/windows/stationMaint.ps1 | 18 - usermgmt/windows/stationMaint2.py | 969 ---------------- usermgmt/windows/stationmaint.ini.sample | 11 - 21 files changed, 2740 deletions(-) delete mode 100644 .github/workflows/build_tools.yml delete mode 100644 fbCollector/README.md delete mode 100644 fbCollector/config.ini.sample delete mode 100644 fbCollector/download_events.sh delete mode 100644 fbCollector/fbCollector.ps1 delete mode 100644 fbCollector/fbcollector.yml delete mode 100644 fbCollector/fireballCollector.py delete mode 100644 fbCollector/fireballcollector_setup.iss delete mode 100644 fbCollector/noimage.jpg delete mode 100644 fbCollector/requirements.txt delete mode 100644 fbCollector/ukmda.ico delete mode 100644 fbCollector/vars.yml delete mode 100644 usermgmt/README.md delete mode 100644 usermgmt/windows/build.ps1 delete mode 100644 usermgmt/windows/cam_management_setup.iss delete mode 100644 usermgmt/windows/camera.ico delete mode 100644 usermgmt/windows/release_notes.md delete mode 100644 usermgmt/windows/requirements.txt delete mode 100644 usermgmt/windows/stationMaint.ps1 delete mode 100644 usermgmt/windows/stationMaint2.py delete mode 100644 usermgmt/windows/stationmaint.ini.sample diff --git a/.github/workflows/build_tools.yml b/.github/workflows/build_tools.yml deleted file mode 100644 index bf764cf49..000000000 --- a/.github/workflows/build_tools.yml +++ /dev/null @@ -1,125 +0,0 @@ -name: Build Fireball and Management Tools -env: - AWS_REGION: eu-west-2 -permissions: - id-token: write - contents: read - -on: - push: - branches: [master] - pull_request: - # The branches below must be a subset of the branches above - branches: [master] -jobs: - local_tests: - name: local test stage - runs-on: win10 - steps: - - name: "☁️ checkout repository" - uses: actions/checkout@v4 - - name: testing - run: | - echo "hello from test" - conda activate ukmon-admin - dir - build_usermgmt: - name: build usermanagement - runs-on: win10 - needs: local_tests - steps: - - name: building - run: | - echo "running build step" - conda activate ukmon-admin - cd usermgmt\windows - pyinstaller ./stationMaint2.py --noconsole --onefile --windowed --icon .\camera.ico - dir dist - package_usermgmt: - name: package usermanagement - runs-on: win10 - needs: - - build_usermgmt - steps: - - name: packaging - uses: Minionguyjpro/Inno-Setup-Action@v1.2.2 - with: - path: usermgmt\windows\cam_management_setup.iss - release_usermgmt: - name: release usermanagement - runs-on: win10 - needs: - - package_usermgmt - steps: - - name: release - uses: xresloader/upload-to-github-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.MM_PAT }} - with: - file: e:\temp\setup_cameraMgmt.exe - tags: true - draft: true - overwrite: true - tag_name: 2025.11.1 - default_release_name: User Management Tool - default_release_body_path: usermgmt\windows\release_notes.md - cleanup_usermgmt: - name: clean up temp cameramgmt files - runs-on: win10 - continue-on-error: true - needs: - - release_usermgmt - steps: - - name: delete temp cameramgmt package - run: | - del e:\temp\setup_cameraMgmt.exe - build_fbtool: - name: build fbcollector - runs-on: win10 - needs: local_tests - steps: - - name: building - run: | - echo "running build step" - conda activate fbcollector - cd fbCollector - pyinstaller ./fireballCollector.py --onefile --windowed --icon .\ukmda.ico - markdown2 ./README.md > README.html - package_fbtool: - name: package fbcollector - runs-on: win10 - needs: - - build_fbtool - steps: - - name: packaging - uses: Minionguyjpro/Inno-Setup-Action@v1.2.2 - with: - path: .\fbCollector\fireballcollector_setup.iss - release_fbtool: - name: release fbcollector - runs-on: win10 - needs: - - package_fbtool - steps: - - name: release - uses: xresloader/upload-to-github-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.MM_PAT }} - with: - file: e:\temp\setup_fireballCollector.exe - tags: true - draft: true - overwrite: true - tag_name: 2025.11.1 - default_release_name: Tools to Gather Fireball data and Manage UKMON Cameras - default_release_body_path: fbCollector\README.md - cleanup_fbtool: - name: clean up temp fbcollector files - runs-on: win10 - continue-on-error: true - needs: - - release_fbtool - steps: - - name: delete package - run: | - del e:\temp\setup_fireballCollector.exe diff --git a/fbCollector/README.md b/fbCollector/README.md deleted file mode 100644 index 6cd3c7596..000000000 --- a/fbCollector/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# A Fireball Collector and Analyser for UKMON and GMN - -This tool allows authorised users to collect fireball data from UKMON and GMN and then to reduce and solve it. - -## Prerequisites - -* You have installed [WMPL](https://github.com/wmpg/WesternMeteorPyLib/), used to solve trajectories. -* You have installed [RMS](https://github.com/CroatianMeteorNetwork/RMS), used to reduce raw data. -* You have a local folder where you will store fireball data. This is called `basedir` in this documentation. - -The above are sufficient to collect data from UKMON and to analyse data either collected from UKMON or by other means. There are additional configuration options that can be used to collect data from GMN and upload solutions to UKMON. These are described inline below. - -## Installation -* Install the package `setup_fireballCollector.exe` from [here](https://github.com/ukmda/ukmda-dataprocessing/releases). -* Install WMPL and RMS and verify they're working. - -## Configuring the App -* Launch the app from the desktop icon or start menu. After a few seconds a text editor will appear to allow you to configure the application. -* Update the values of `basedir`, `rms_loc`, `rms_env`, `wmpl_loc`, and `wmpl_env` as appropriate and save. - -## Using the App -* Enter a date and time in the format `YYYYMMDD_HHMMSS` into the `Image Selection` box, then click "Get Images". - * After a few seconds, the listbox below should be populated with images from around the time you selected, provided the UKMON live feed captured something. - * If nothing appears, check the UKMON [livestream](https://archive.ukmeteors.co.uk/live/index.html) to make sure you chose a suitable time. - -* Go through the image list and click `Remove` or press the `Delete` key to delete any that are not of the event you are interested in. -* Once you've whittled the list down to just the interesting events, you can select `Get ECSVs` from the `Raw` menu. This will attempt to get raw data for each image. You can also click `Get Videos` to collect any video data thats available. - -* Now you can click `Solve` from the `Solve` menu. This will invoke WMPL and will attempt to find a trajectory that matches the raw data. It may take some time. -* If the solver is successful, you can view the solution by selecting `View Solution` from the `Solve` menu. The left-hand list will now show the output of the solver so you can examine it. You can switch back to viewing the raw images by selecting `Review Images` from the `Review` menu. - -* If the solve process fails or if the solution seems very poor then try excluding some detections. To do this, select `Excl/Incl ECSV` from the `Raw` menu then rerun the Solver. -* If the solution was bad its also worth deleting it before attempting a rerun via `Delete Solution` from the `Solve` menu. - -## Manually Reducing an Image -If you've installed RMS and you have a FITS or FR file from RMS along with the camera's config and platepar files, you can reduce the data using RMS's `SkyFit2` tool. Select an image in the file list then select `Reduce Selected Image` from the `Raw` menu. - -## Sending Solutions to UKMON -Once you have a solution, select `Upload orbit` from the `Solve` menu. This will create a Zip file that bundles the created trajectory pickle file with any images and videos. You can then upload the file to Dropbox, Google Drive or another file-sharing site and email a link to [us](fireballdata@ukmeteornetwork.org) where one of our team will check and upload it to our Archive. - -#### API Key -Members of the UKMON team who frequently create solutions can request an API key to directly upload solutions less than 10 MB in size. We'll contact you if we think this is applicable. - -## Sharing Raw Data -If you've configured a raw data location in the configuration file, `Share Raw Data` on the raw menu will create a zip file of all the raw data and copy it to the location. For example, if you have Dropbox installed and set the share location to a folder in your Dropbox, then the zip file will be copied there. - -### Collecting data from GMN -Members of the GMN Coordinators group who have permission from Denis Vida can use this tool to collect raw data directly from GMN. - -If you fall into this category you can fill in the `[gmn]` section of the config file with the name of your SSH private key and other details. This will activate additional menu options to `Get GMN Raw Data` and to use the `Watchlist`. - -*Note that you will need WSL2 enabled and the rsync tool installed in WSL2 to run this process.* - -## Logs -You can view the logs from the `File` menu. The logs are created in [%TEMP%/fbcollector](%temp%/fbcollector). - - diff --git a/fbCollector/config.ini.sample b/fbCollector/config.ini.sample deleted file mode 100644 index 2128f65dd..000000000 --- a/fbCollector/config.ini.sample +++ /dev/null @@ -1,32 +0,0 @@ -# tailor this config file to your needs -# Note that $HOME will be translated into your homedir (~ on linux, $env:userprofile on windows) - -[Fireballs] -# the location to which fireball data will be downloaded eg c:\fireballs. -basedir= - -[reduction] -# To use the reduction options RMS must be installed and an RMS python conda environment created -rms_loc= -rms_env=RMS - -[solver] -# To use the solver options WMPL must be installed and a WMPL python conda environment created -wmpl_loc= -wmpl_env=wmpl - -[sharing] -# create a folder in Dropbox or similar if you want to share raw data with others and then put -# its *local* path here eg c:\users\yourname\dropbox\meteors -shrfldr= - -[gmn] -# the GMN SSH key needs to be configured with Denis Vida -# if you have permission you can then collect data from the GMN server -gmnkey= -gmnuser= -gmnserver=gmn.uwo.ca - -[ukmon] -apikey= - diff --git a/fbCollector/download_events.sh b/fbCollector/download_events.sh deleted file mode 100644 index c491c37b4..000000000 --- a/fbCollector/download_events.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/bin/bash - -here="$( cd "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" - -dt=$1 -basedir=$2 -getall=$3 - -mkdir -p $basedir/$dt -pushd $basedir/$dt - -rsync -avz gmn.uwo.ca:/home/uk*/files/event_monitor/*${dt}*.bz2 . -rsync -avz gmn.uwo.ca:/home/be*/files/event_monitor/*${dt}*.bz2 . -rsync -avz gmn.uwo.ca:/home/ie*/files/event_monitor/*${dt}*.bz2 . -rsync -avz gmn.uwo.ca:/home/nl*/files/event_monitor/*${dt}*.bz2 . -if [ "$getall" == "all" ] ; then - rsync -avz gmn.uwo.ca:/home/fr*/files/event_monitor/*${dt}*.bz2 . - rsync -avz gmn.uwo.ca:/home/de*/files/event_monitor/*${dt}*.bz2 . - rsync -avz gmn.uwo.ca:/home/es*/files/event_monitor/*${dt}*.bz2 . - rsync -avz gmn.uwo.ca:/home/ch*/files/event_monitor/*${dt}*.bz2 . - rsync -avz gmn.uwo.ca:/home/it*/files/event_monitor/*${dt}*.bz2 . - rsync -avz gmn.uwo.ca:/home/cz*/files/event_monitor/*${dt}*.bz2 . - rsync -avz gmn.uwo.ca:/home/hr*/files/event_monitor/*${dt}*.bz2 . - rsync -avz gmn.uwo.ca:/home/sk*/files/event_monitor/*${dt}*.bz2 . -fi - -for f in *.bz2 ; do tar -xvf $f ; done -if [ -d UKMON ] ; then - sites=$(ls -1 UKMON) - for site in $sites ; do - cams=$(ls -1 UKMON/$site) - for cam in $cams ; do - if [ -d ./$cam ] ; then rm -Rf $cam ; fi - mv -f UKMON/$site/$cam . - done - done - rm -Rf UKMON -fi -if [ -d NEMETODE ] ; then - sites=$(ls -1 NEMETODE) - for site in $sites ; do - cams=$(ls -1 NEMETODE/$site) - for cam in $cams ; do - if [ -d ./$cam ] ; then rm -Rf $cam ; fi - mv -f NEMETODE/$site/$cam . - done - done - rm -Rf NEMETODE -fi -if [ -d "UKMON,NEMETODE" ] ; then - sites=$(ls -1 "UKMON,NEMETODE") - for site in $sites ; do - cams=$(ls -1 "UKMON,NEMETODE/$site") - for cam in $cams ; do - if [ -d ./$cam ] ; then rm -Rf $cam ; fi - mv -f "UKMON,NEMETODE/$site/$cam" . - done - done - rm -Rf "UKMON,NEMETODE" -fi -mkdir -p ./stacks -mkdir -p ./jpgs -mkdir -p ./mp4s -cp -f */*.jpg ./jpgs -mv -f ./jpgs/*captured_stack* ./stacks -cp -f */*.mp4 ./mp4s -popd \ No newline at end of file diff --git a/fbCollector/fbCollector.ps1 b/fbCollector/fbCollector.ps1 deleted file mode 100644 index 52d6e2c17..000000000 --- a/fbCollector/fbCollector.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -# -# powershell script to launch the fireball data collector tool -# Copyright (C) 2018-2023 Mark McIntyre -# -push-location $PSScriptRoot - -$wmpl_env=((select-string .\config.ini -pattern "wmpl_env" -list).line).split('=')[1] -$wmpl_loc=((select-string .\config.ini -pattern "wmpl_loc" -list).line).split('=')[1] - -conda activate $wmpl_env - -$wmpl_loc = $wmpl_loc.replace('$HOME',$env:userprofile) -$wmpl_loc = $wmpl_loc.replace('\','/') - -$env:pythonpath="$wmpl_loc" - -if ($args.count -lt 1) { - python $PSScriptRoot/fireballCollector.py -}else { - python $PSScriptRoot/fireballCollector.py -d $args[0] -} - -Pop-Location - - diff --git a/fbCollector/fbcollector.yml b/fbCollector/fbcollector.yml deleted file mode 100644 index 83463e685..000000000 --- a/fbCollector/fbcollector.yml +++ /dev/null @@ -1,24 +0,0 @@ ---- -- hosts: ukmonhelper2 - gather_facts: no - tasks: - - name: import variables - include_vars: ./vars.yml - tags: [dev,prod] - - name: Ensures {{destdir}} exists - file: path={{destdir}} state=directory - delegate_to: localhost - tags: [dev,prod] - - name: Copy files - copy: src={{ item.src }} dest={{ item.dest }} mode={{ item.mode }} - tags: [dev,prod] - delegate_to: localhost - with_items: - - {src: '{{srcdir}}/fbCollector.ps1', dest: '{{destdir}}/', mode: '755', backup: no, directory_mode: no } - - {src: '{{srcdir}}/fireballCollector.py', dest: '{{destdir}}/', mode: '644', backup: no, directory_mode: no } - - {src: '{{srcdir}}/download_events.sh', dest: '{{destdir}}/', mode: '755', backup: no, directory_mode: no } - - {src: '{{srcdir}}/config.ini.sample', dest: '{{destdir}}/', mode: '644', backup: no, directory_mode: no } - - {src: '{{srcdir}}/noimage.jpg', dest: '{{destdir}}/', mode: '644', backup: no, directory_mode: no } - - {src: '{{srcdir}}/ukmda.ico', dest: '{{destdir}}/', mode: '644', backup: no, directory_mode: no } - - {src: '{{srcdir}}/requirements.txt', dest: '{{destdir}}/', mode: '644', backup: no, directory_mode: no } - diff --git a/fbCollector/fireballCollector.py b/fbCollector/fireballCollector.py deleted file mode 100644 index 00d7c92f8..000000000 --- a/fbCollector/fireballCollector.py +++ /dev/null @@ -1,1244 +0,0 @@ -# -# UI to manage fireball data collection -# Copyright (C) 2018-2023 Mark McIntyre -# -import os -import sys -import argparse -import logging -import logging.handlers -import datetime -import configparser -import shutil -import platform -import subprocess -import glob -import xmltodict -from PIL import Image -import requests -import pandas as pd -import webbrowser - -import paramiko -from scp import SCPClient - - -import tkinter as tk -import tkinter.filedialog as tkFileDialog -import tkinter.messagebox as tkMessageBox -from tkinter.simpledialog import askstring -from tkinter import StringVar, Frame, ACTIVE, END, Listbox, Menu, Entry, Button -from tkinter.ttk import Label, Style, LabelFrame, Scrollbar - -from PIL import Image as img -from PIL import ImageTk - - -config_file = '' -noimg_file = '' -global_bg = "Black" -global_fg = "Gray" - - -def quitApp(): - # Cleanly exits the app - root.quit() - root.destroy() - - -def log_timestamp(): - """ Returns timestamp for logging. - """ - return datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') - - -def showConfig(): - if platform.system() == 'Darwin': # macOS - procid = subprocess.Popen(('open', config_file)) - elif platform.system() == 'Windows': # Windows - procid = subprocess.Popen(('cmd','/c',config_file)) - else: # linux variants - procid = subprocess.Popen(('xdg-open', config_file)) - procid.wait() - - -class StyledButton(Button): - """ Button with style. - """ - def __init__(self, *args, **kwargs): - Button.__init__(self, *args, **kwargs) - - self.configure(foreground = global_fg, background = global_bg, borderwidth = 3) - - -class StyledEntry(Entry): - """ Entry box with style. - """ - def __init__(self, *args, **kwargs): - Entry.__init__(self, *args, **kwargs) - - self.configure(foreground = global_fg, background = global_bg, insertbackground = global_fg, disabledbackground = global_bg, disabledforeground = "DimGray") - - -class ConstrainedEntry(StyledEntry): - """ Entry box with constrained values which can be input (e.g. 0-255). - """ - def __init__(self, *args, **kwargs): - StyledEntry.__init__(self, *args, **kwargs) - self.maxvalue = 255 - vcmd = (self.register(self.on_validate), "%P") - self.configure(validate="key", validatecommand=vcmd) - # self.configure(foreground = global_fg, background = global_bg, insertbackground = global_fg) - - def disallow(self): - """ Pings a bell on values which are out of bound. - """ - self.bell() - - def update_value(self, maxvalue): - """ Updates values in the entry box. - """ - self.maxvalue = maxvalue - vcmd = (self.register(self.on_validate), "%P") - self.configure(validate="key", validatecommand=vcmd) - - def on_validate(self, new_value): - """ Checks if entered value is within bounds. - """ - try: - if new_value.strip() == "": - return True - value = int(new_value) - if value < 0 or value > self.maxvalue: - self.disallow() - return False - except ValueError: - self.disallow() - return False - - return True - - -def getECSVs(stationID, dateStr, savefiles=False, outdir='.'): - """ - Retrieve a detection in ECSV format for the specified date - """ - apiurl='https://api.ukmeteors.co.uk/getecsv?stat={}&dt={}' - res = requests.get(apiurl.format(stationID, dateStr)) - ecsvlines='' - if res.status_code == 200: - rawdata=res.text.strip() - if len(rawdata) > 10: - ecsvlines=rawdata.split('\n') # convert the raw data into a python list - if savefiles is True: - os.makedirs(outdir, exist_ok=True) - fnamebase = dateStr.replace(':','_').replace('.','_') # create an output filename - j=0 - outf = False - for li in ecsvlines: - if 'issue getting data' in li: - print(li) - return li - if '# %ECSV' in li: - if outf is not False: - outf.close() - j=j+1 - fname = fnamebase + f'_ukmda_{stationID}_M{j:03d}.ecsv' - outf = open(os.path.join(outdir, fname), 'w') - print('saving to ', os.path.join(outdir,fname)) - if outf: - outf.write(f'{li}\n') - else: - print('no ECSV marker found in data') - else: - print('no error, but no data returned') - else: - print(res.status_code) - return ecsvlines - - -def _download(url, outdir, fname=None): - get_response = requests.get(url, stream=True) - if fname is None: - fname = url.split('?')[0].split("/")[-1] - with open(os.path.join(outdir, fname), 'wb') as f: - for chunk in get_response.iter_content(chunk_size=4096): - if chunk: # filter out keep-alive new chunks - f.write(chunk) - return fname - - -def getLiveJpgs(dtstr, outdir=None): - """ - Retrieve live images from the ukmon website that match a pattern - """ - if outdir is None: - outdir = dtstr - os.makedirs(outdir, exist_ok=True) - - apiurl = 'https://api.ukmeteors.co.uk/liveimages/getlive' - - while len(dtstr) < 15: - dtstr = dtstr + '0' - isodt1 = datetime.datetime.strptime(dtstr[:15],'%Y%m%d_%H%M%S') - fromdstr = isodt1.isoformat()[:19]+'.000Z' - isodt2 = isodt1 + datetime.timedelta(minutes=1) - todstr = isodt2.isoformat()[:19]+'.000Z' - liveimgs = pd.read_json(f'{apiurl}?dtstr={fromdstr}&enddtstr={todstr}&fmt=withxml') - - for _, thisimg in liveimgs.iterrows(): - try: - jpgurl = thisimg .urls['url'] - fname = _download(jpgurl, outdir) - log.info(f'retrieved {fname}') - except: - log.warning(f'{img.image_name} unavailable') - - -class fbCollector(Frame): - - def __init__(self, parent, patt=None): - Frame.__init__(self, parent, bg = global_bg) - parent.configure(bg = global_bg) # Set backgound color - parent.grid_columnconfigure(0, weight=1) - parent.grid_rowconfigure(0, weight=1) - - self.grid(sticky="NSEW") # Expand frame to all directions - self.parent = parent - - self.fb_dir = '' - self.gmn_key = '' - self.gmn_user = '' - self.gmn_server = '' - self.wmpl_loc = '' - self.wmpl_env = '' - self.rms_loc = '' - self.rms_env = '' - self.api_key = None - self.share_loc = '' - self.selected = {} - self.evtMonTriggered = None - self.review_stack = False - self.soln_outputdir = None - self.log_files_to_keep = 30 - self.script_loc = os.path.split(config_file)[0] - - self.readConfig() - - self.patt = patt - if patt is None: - self.dir_path = self.fb_dir.strip() - else: - self.dir_path = os.path.join(self.fb_dir, patt) - log.info(f'Fireball folder is {self.fb_dir}') - log.info(f'Scripts folder is {self.script_loc}') - - self.initUI() - if self.dir_path != self.fb_dir: - bin_list = self.get_bin_list() - for b in bin_list: - self.selected[b] = (0, '') - self.update_listbox(bin_list) - - # Update UI changes - parent.update_idletasks() - parent.update() - - return - - def readConfig(self): - localcfg = configparser.ConfigParser() - localcfg.read(config_file) - self.fb_dir = os.path.expanduser(localcfg['Fireballs']['basedir'].replace('$HOME','~')).replace('\\','/') - os.makedirs(self.fb_dir, exist_ok=True) - - self.gmn_key = None - self.gmn_user = None - self.gmn_server = None - if localcfg.has_option('gmn','gmnkey'): - self.gmn_key = localcfg['gmn']['gmnkey'] - self.gmn_user = localcfg['gmn']['gmnuser'] - self.gmn_server = localcfg['gmn']['gmnserver'] - - self.api_key = None - if localcfg.has_option('ukmon','apikey'): - self.api_key = localcfg['ukmon']['apikey'] - if os.path.isfile(os.path.expanduser(self.api_key)): - key = open(os.path.expanduser(self.api_key), 'r').readlines()[0].strip() - else: - key = self.api_key - if len(key) < 40: - key = None - self.api_key = key - - self.wmpl_loc = None - if localcfg.has_option('solver','wmpl_loc'): - self.wmpl_loc = os.path.expanduser(localcfg['solver']['wmpl_loc'].replace('$HOME','~')).replace('\\','/') - self.wmpl_env= localcfg['solver']['wmpl_env'] - log.info(f'wmpl_loc {self.wmpl_loc}') - - self.rms_loc = None - if localcfg.has_option('reduction','rms_loc'): - self.rms_loc = os.path.expanduser(localcfg['reduction']['rms_loc'].replace('$HOME','~')).replace('\\','/') - self.rms_env = localcfg['reduction']['rms_env'] - - - self.share_loc = None - if localcfg.has_option('sharing','shrfldr'): - self.share_loc = os.path.expanduser(localcfg['sharing']['shrfldr'].replace('$HOME','~')).replace('\\','/') - - def quitApplication(self): - print('quitting') - logdir = os.path.join(os.getenv('TMP'), 'fbcollector') - logfiles = os.listdir(logdir) - numtokeep = self.log_files_to_keep - if len(logfiles) > numtokeep: - logfiles.sort() - for i in range(len(logfiles)-numtokeep): - try: - os.remove(os.path.join(logdir, logfiles[i])) - except: - pass - quitApp() - - def initUI(self): - """ Initialize GUI elements. - """ - - self.parent.title("Fireball Downloader") - - # Configure the style of each element - s = Style() - s.configure("TButton", padding=(0, 5, 0, 5), font='serif 10') - s.configure('TLabelframe.Label', foreground=global_fg, background=global_bg) - s.configure('TLabelframe', foreground =global_fg, background=global_bg, padding=(3, 3, 3, 3)) - s.configure("TRadiobutton", foreground = global_fg, background = global_bg) - s.configure("TLabel", foreground = global_fg, background = global_bg) - s.configure("TCheckbutton", foreground = global_fg, background = global_bg) - s.configure("Vertical.TScrollbar", background=global_bg, troughcolor = global_bg) - - self.columnconfigure(0, pad=3) - self.columnconfigure(1, pad=3) - self.columnconfigure(2, pad=3) - self.columnconfigure(3, pad=3) - self.columnconfigure(4, pad=3) - self.columnconfigure(5, pad=3) - self.columnconfigure(6, pad=3) - self.columnconfigure(7, pad=3) - self.columnconfigure(8, pad=3) - - self.rowconfigure(0, pad=3) - self.rowconfigure(1, pad=3) - self.rowconfigure(2, pad=3) - self.rowconfigure(3, pad=3) - self.rowconfigure(4, pad=3) - self.rowconfigure(5, pad=3) - self.rowconfigure(6, pad=3) - self.rowconfigure(7, pad=3) - self.rowconfigure(8, pad=3) - self.rowconfigure(9, pad=3) - self.rowconfigure(10, pad=3) - - # Make menu - self.menuBar = Menu(self.parent) - self.parent.config(menu=self.menuBar) - - # File menu - fileMenu = Menu(self.menuBar, tearoff=0) - fileMenu.add_command(label="Load Folder", command=self.loadFolder) - fileMenu.add_command(label="Archive This Folder", command=self.archiveFolder) - fileMenu.add_command(label="Open in Explorer", command=self.openFolder) - fileMenu.add_separator() - fileMenu.add_command(label="Delete This Folder", command=self.delFolder) - fileMenu.add_separator() - fileMenu.add_command(label="Configuration", command=self.reviewConfig) - fileMenu.add_command(label="View Logs", command=self.viewLogs) - fileMenu.add_separator() - fileMenu.add_command(label="Exit", command=self.quitApplication) - self.menuBar.add_cascade(label="File", underline=0, menu=fileMenu) - - if self.rms_loc: - rmsavailable = 'active' - else: - rmsavailable = 'disabled' - if self.share_loc: - shareavailable = 'active' - else: - shareavailable = 'disabled' - if self.gmn_key: - gmnavailable ='active' - else: - gmnavailable ='disabled' - if self.wmpl_loc: - wmplavailable = 'active' - else: - wmplavailable = 'disabled' - - log.info(f'rms available {rmsavailable}') - log.info(f'share available {shareavailable}') - log.info(f'gmn available {gmnavailable}') - log.info(f'wmpl available {wmplavailable}') - - rawMenu = Menu(self.menuBar, tearoff=0) - rawMenu.add_command(label="Get Live Images", command=self.getData) - rawMenu.add_command(label="Get Videos", command=self.getVids) - rawMenu.add_separator() - rawMenu.add_command(label="Get ECSVs", command=self.getRequestedECSVs) - rawMenu.add_command(label="Excl/Incl ECSV", command=self.ignoreCamera) - rawMenu.add_command(label="Reduce Selected Image", command=self.reduceCamera, state=rmsavailable) - rawMenu.add_separator() - rawMenu.add_command(label="Share Raw Data", command=self.uploadRaw, state=shareavailable) - rawMenu.add_separator() - watchMenu = Menu(self.menuBar, tearoff=0) - watchMenu.add_command(label="Get GMN Raw Data", command=self.getGMNData, state=gmnavailable) - watchMenu.add_separator() - watchMenu.add_command(label="Get Watchlist", command=self.getWatchlist, state=gmnavailable) - watchMenu.add_command(label="View Watchlist", command=self.viewWatchlist, state=gmnavailable) - watchMenu.add_command(label="Upload Watchlist", command=self.putWatchlist, state=gmnavailable) - watchMenu.add_separator() - watchMenu.add_command(label="Fetch Watchlist Event", command=self.getEventData, state=gmnavailable) - rawMenu.add_cascade(label='GMN', menu=watchMenu, state=gmnavailable) - - self.menuBar.add_cascade(label="Raw", underline=0, menu=rawMenu) - - revMenu = Menu(self.menuBar, tearoff=0) - revMenu.add_command(label="Review Stacks", command=self.checkStacks) - revMenu.add_command(label="Review Images", command=self.viewData) - revMenu.add_command(label="Clean Folder", command=self.clean_folder) - self.menuBar.add_cascade(label="Review", underline=0, menu=revMenu) - - solveMenu = Menu(self.menuBar, tearoff=0) - solveMenu.add_command(label="Solve", command=self.solveOrbit, state=wmplavailable) - solveMenu.add_separator() - solveMenu.add_command(label="View Solution", command=self.viewSolution) - solveMenu.add_command(label="Delete Solution", command=self.removeSolution) - solveMenu.add_separator() - solveMenu.add_command(label="Upload Solution", command=self.uploadOrbit) - self.menuBar.add_cascade(label="Solve", underline=0, menu=solveMenu) - - otherMenu = Menu(self.menuBar, tearoff=0) - otherMenu.add_command(label="Get Traj Pickle", command=self.getTrajpickle) - otherMenu.add_command(label="Add Image/Vid", command=self.addImageVideo) - otherMenu.add_separator() - otherMenu.add_command(label="Delete Orbit", command=self.delOrbit) - self.menuBar.add_cascade(label="Other", underline=0, menu=otherMenu) - - helpMenu = Menu(self.menuBar, tearoff=0) - helpMenu.add_command(label="Documentation", command=self.showDocs) - self.menuBar.add_cascade(label="Help", underline=0, menu=helpMenu) - # buttons - self.save_panel = LabelFrame(self, text=' Image Selection ') - self.save_panel.grid(row = 1, columnspan = 2, sticky='WE') - - self.newpatt = StringVar() - self.newpatt.set(self.patt) - - self.patt_entry = StyledEntry(self.save_panel, textvariable = self.newpatt, width = 20) - self.patt_entry.grid(row = 1, column = 1, columnspan = 2, sticky = "W") - save_bmp = StyledButton(self.save_panel, text="Get Images", width = 8, command = lambda: self.getData()) - save_bmp.grid(row = 1, column = 3) - - save_bmp = StyledButton(self.save_panel, text="Remove", width = 8, command = lambda: self.remove_image()) - save_bmp.grid(row = 1, column = 4) - - # Listbox - self.scrollbar = Scrollbar(self) - self.listbox = Listbox(self, width = 47, yscrollcommand=self.scrollbar.set, exportselection=0, activestyle = "none", bg = global_bg, fg = global_fg) - self.listbox.config(height = 25) # Listbox size - self.listbox.grid(row = 4, column = 0, rowspan = 7, columnspan = 2, sticky = "NS") # Listbox position - self.scrollbar.grid(row = 4, column = 2, rowspan = 7, sticky = "NS") # Scrollbar size - - self.listbox.bind('<>', self.update_image) - self.scrollbar.config(command = self.listbox.yview) - - # bind delete key - self.listbox.bind("", lambda x: self.remove_image()) - - # IMAGE - try: - # Show the TV test card image on program start - #noimage_data = img.open('noimage.bin').resize((640,360)) - noimgdata = img.open(noimg_file).resize((640,360)) - noimage = ImageTk.PhotoImage(noimgdata) - except: - noimage = None - - self.imagelabel = Label(self, image = noimage) - self.imagelabel.image = noimage - self.imagelabel.grid(row=3, column=3, rowspan = 4, columnspan = 3) - - # Timestamp label - self.timestamp_label = Label(self, text = "CCNNNN YYYY-MM-DD HH:MM.SS.mms", font=("Courier", 12)) - self.timestamp_label.grid(row = 7, column = 3, sticky = "E") - - def reviewConfig(self): - showConfig() - self.readConfig() - self.initUI() - - def showDocs(self): - webbrowser.open('README.html') - return - - def reduceCamera(self): - current_image = self.listbox.get(ACTIVE) - if current_image == '': - return - camid = current_image[3:9] - print('selected camera is', camid) - dirname = os.path.join(self.dir_path, camid) - tmpscr = os.path.join(os.getenv('TEMP'), 'reduce.ps1') - with open(tmpscr, 'w') as outf: - outf.write(f'cd {self.rms_loc}\nconda activate {self.rms_env}\npython -m Utils.SkyFit2 {dirname} -c {dirname}/.config\n') - _ = subprocess.run(['powershell.exe', tmpscr]) - frs = glob.glob(os.path.join(dirname, 'FR*.bin')) - if len(frs) > 0: - if tkMessageBox.askyesno("Rerun", f'{len(frs)} FR files detected - rerun?'): - for fr in frs: - with open(tmpscr, 'w') as outf: - outf.write(f'cd {self.rms_loc}\nconda activate {self.rms_env}\npython -m Utils.SkyFit2 {fr} -c {dirname}/.config\n') - _ = subprocess.run(['powershell.exe', tmpscr]) - try: - os.remove(tmpscr) - except: - pass - os.makedirs(os.path.join(self.dir_path,'ecsvs'), exist_ok=True) - ecsvfs = glob.glob1(dirname, '*.ecsv') - for ecsv in ecsvfs: - shutil.copyfile(os.path.join(dirname, ecsv), os.path.join(self.dir_path, 'ecsvs', ecsv)) - - return - - def ignoreCamera(self): - current_image = self.listbox.get(ACTIVE) - if current_image == '': - return - camid = current_image[3:9] - log.info(f'selected camera is {camid}') - dirname = os.path.join(self.dir_path, camid) - allecsvs = glob.glob(os.path.join(dirname, '*.ecsv')) - ecsvs = [e for e in allecsvs if 'REJECT' not in e.upper()] - rejs = [e for e in allecsvs if 'REJECT' in e.upper()] - if len(ecsvs) == 0 and len(rejs) == 0: - log.info('no files to reject or include') - return - elif len(ecsvs) > 0 and len(rejs) == 0: - for ecsv in ecsvs: - os.rename(ecsv, ecsv.replace('.ecsv','_REJECT.ecsv')) - jpgnames = glob.glob(os.path.join(self.dir_path, 'jpgs', current_image)) - for jpgname in jpgnames: - os.rename(jpgname, jpgname.replace('.jpg','_REJECT.jpg')) - elif len(ecsvs) == 0 and len(rejs) > 0: - for rej in rejs: - os.rename(rej, rej.replace('_REJECT.ecsv','.ecsv')) - jpgnames = glob.glob(os.path.join(self.dir_path, 'jpgs', current_image)) - for jpgname in jpgnames: - os.rename(jpgname, jpgname.replace('_REJECT.jpg','.jpg')) - else: - # more than one ECSV or REJ file to handle, urk - log.info('urk') - return - bin_list = [line for line in os.listdir(os.path.join(self.dir_path, 'jpgs')) if self.correct_datafile_name(line)] - #print(bin_list) - for b in bin_list: - self.selected[b] = (0, '') - self.update_listbox(bin_list) - return - - def solveOrbit(self): - log.info('Using ECSV files:') - ecsv_names = [] - ecsv_loc = os.path.join(self.dir_path,'ecsvs') - os.makedirs(ecsv_loc, exist_ok=True) - for entry in sorted(os.walk(self.dir_path), key=lambda x: x[0]): - dir_name, _, file_names = entry - if 'ecsvs' in dir_name: - continue - for fn in file_names: - if fn.lower().endswith(".ecsv"): - shutil.copyfile(os.path.join(dir_name, fn), os.path.join(ecsv_loc, fn)) - if fn.lower().endswith(".ecsv") and 'REJECT' not in dir_name.upper() and 'REJECT' not in fn.upper(): - # Add ECSV file, but skip duplicates - if fn not in ecsv_names: - ecsv_names.append(fn) - log.info(fn) - if len(ecsv_names) < 2: - tkMessageBox.showinfo('Warning', 'Need at least two ECSV files') - return - - tmpscr = os.path.join(os.getenv('TEMP'), 'solve.ps1') - with open(tmpscr, 'w') as outf: - mcruns = 20 - outf.write(f'cd {self.wmpl_loc}\nconda activate {self.wmpl_env}\npython -m wmpl.Formats.ECSV {ecsv_loc} -l -x -r {mcruns} -w -t 15\n') - _ = subprocess.run(['powershell.exe', tmpscr]) - fldrs = os.listdir(ecsv_loc) - print(fldrs) - fldrs = [f for f in fldrs if os.path.isdir(os.path.join(ecsv_loc, f))] - if len(fldrs) > 0: - log.info(f'solved into {fldrs[0]}') - self.soln_outputdir = os.path.join(self.dir_path, fldrs[0]) - if os.path.isdir(self.soln_outputdir): - shutil.rmtree(self.soln_outputdir) - log.info(f'moving {os.path.join(ecsv_loc, fldrs[0])} to {self.dir_path}') - shutil.move(os.path.join(ecsv_loc, fldrs[0]), self.dir_path) - tkMessageBox.showinfo('Info', 'Solver Finished') - return - - def viewSolution(self): - self.review_stack = False - if not self.soln_outputdir: - solndir = glob.glob1(self.dir_path, os.path.split(self.dir_path)[1][:8]+'*') - solndir = [f for f in solndir if os.path.isdir(os.path.join(self.dir_path, f))] - if len(solndir) == 0: - tkMessageBox.showinfo('Warning', 'No solution to review') - return - solndir = os.path.join(self.dir_path, solndir[0]) - self.soln_outputdir = solndir - bin_list = [line for line in os.listdir(self.soln_outputdir) if self.correct_datafile_name(line)] - for b in bin_list: - self.selected[b] = (0, '') - self.update_listbox(bin_list) - return - - def removeSolution(self): - if not self.soln_outputdir: - solndir = glob.glob1(self.dir_path, os.path.split(self.dir_path)[1][:8]+'*') - if len(solndir) == 0: - tkMessageBox.showinfo('Warning', 'No solution to remove') - return - solndir = os.path.join(self.dir_path, solndir[0]) - self.soln_outputdir = solndir - if not tkMessageBox.askyesno("Delete file", f"delete {self.soln_outputdir}?"): - return - shutil.rmtree(os.path.join(self.dir_path, self.soln_outputdir)) - return - - def uploadOrbit(self): - return uploadOrbitGeneric(self.dir_path, self.api_key) - - def uploadRaw(self): - zfname = os.path.join(os.getenv('TMP'), os.path.basename(self.dir_path)) - log.info(f'zfname is {zfname}') - shutil.make_archive(zfname,'zip',self.dir_path) - try: - targname = os.path.join(self.share_loc, os.path.basename(zfname)+'.zip') - log.info(f'targname is {targname}') - shutil.copyfile(zfname+'.zip', targname) - tkMessageBox.showinfo('Info', 'Raw Data Uploaded to Dropbox') - subprocess.Popen(f'explorer "{self.share_loc}"') - except Exception: - tkMessageBox.showinfo('Warning', 'Problem with upload') - return - - def viewData(self): - self.review_stack = False - self.soln_outputdir = None - print(self.dir_path) - bin_list = self.get_bin_list() - for b in bin_list: - self.selected[b] = (0, '') - self.update_listbox(bin_list) - return - - def getRequestedECSVs(self): - notgotlist=[] - img_list = self.get_bin_list() - for current_image in img_list: - if not self.getOneEcsv(current_image): - notgotlist.append(current_image) - if len(notgotlist) > 0: - tkMessageBox.showinfo('Info', f'No ECSVs for {notgotlist}') - return - - def getOneEcsv(self, current_image): - if current_image[:1] == 'M': - datestr = current_image[1:16] - statid = current_image[-11:-5] - elif current_image[:2] == 'FF': - statid = current_image[3:9] - datestr = current_image[10:29] - else: - return - #dtval = datetime.datetime.strptime(datestr, '%Y%m%d_%H%M%S') - #datestr = dtval.strftime('%Y-%m-%dT%H:%M:%S') - try: - lis = getECSVs(statid, datestr, savefiles=True, outdir=os.path.join(self.dir_path, statid)) - for li in lis: - if 'issue getting data' in li: - return False - os.makedirs(os.path.join(self.dir_path,'ecsvs'), exist_ok=True) - ecsvfs = glob.glob1(os.path.join(self.dir_path, statid), '*.ecsv') - for ecsv in ecsvfs: - shutil.copyfile(os.path.join(self.dir_path, statid, ecsv), os.path.join(self.dir_path, 'ecsvs', ecsv)) - - shutil.copyfile() - ## finish here - return True - except Exception: - return False - - def get_bin_list(self): - """ Get a list of image files in a given directory. - """ - if self.dir_path is None: - dirname = tkFileDialog.askdirectory(parent=root,initialdir=self.fb_dir, - title='Please select a directory') - if not dirname: - return - _, thispatt = os.path.split(dirname) - self.dir_path = dirname - self.patt = thispatt - self.newpatt.set(self.patt) - - if self.review_stack is False: - targdir = os.path.join(self.dir_path, 'jpgs') - else: - targdir = os.path.join(self.dir_path, 'stacks') - if os.path.isdir(targdir): - bin_list = [line for line in os.listdir(targdir) if self.correct_datafile_name(line)] - else: - log.info('no jpgs available') - bin_list = [] - return bin_list - - def update_listbox(self, bin_list): - """ Updates the listbox with the current entries. - """ - self.listbox.delete(0, END) - for line in sorted(bin_list): - self.listbox.insert(END, line) - if line not in self.selected: - self.selected[line] = (0, '') - if self.selected[line][0] == 1: - self.listbox.itemconfig(END, fg = 'green') - - def checkStacks(self): - self.review_stack = True - bin_list = self.get_bin_list() - if len(bin_list) > 0: - for b in bin_list: - self.selected[b] = (0, '') - self.update_listbox(bin_list) - else: - tkMessageBox.showinfo('Warning', 'No stacks to review') - - def loadFolder(self): - self.review_stack = False - self.dir_path = None - self.soln_outputdir = None - bin_list = self.get_bin_list() - for b in bin_list: - self.selected[b] = (0, '') - self.update_listbox(bin_list) - - def openFolder(self): - dir_path = self.dir_path.replace("/","\\") - os.system(f'explorer.exe {dir_path}') - - def viewLogs(self): - logdir = os.path.join(os.getenv('TMP'), 'fbcollector') - os.system(f'explorer.exe {logdir}') - - def archiveFolder(self): - noimgdata = img.open(noimg_file).resize((640,360)) - noimage = ImageTk.PhotoImage(noimgdata) - self.imagelabel.configure(image = noimage) - self.imagelabel.image = noimage - if self.dir_path == self.fb_dir.strip(): - self.loadFolder() - if self.dir_path is not None and self.dir_path != self.fb_dir: - try: - _, fldr = os.path.split(os.path.normpath(self.dir_path)) - yr = fldr[:4] - archdir = os.path.join(self.fb_dir, yr) - os.makedirs(archdir, exist_ok=True) - zfname = os.path.join(archdir, fldr) - shutil.make_archive(zfname,'zip',self.fb_dir, fldr) - except Exception as e: - log.warning(f'unable to create archive {self.dir_path}') - log.warning(e) - try: - shutil.rmtree(self.dir_path) - except Exception as e: - log.warning(f'unable to remove folder, please do it manually {self.dir_path}') - log.warning(e) - self.dir_path = self.fb_dir - self.dir_path = self.fb_dir - self.viewData() - - def delFolder(self): - noimgdata = img.open(noimg_file).resize((640,360)) - noimage = ImageTk.PhotoImage(noimgdata) - self.imagelabel.configure(image = noimage) - self.imagelabel.image = noimage - if self.dir_path is not None and self.dir_path != self.fb_dir: - try: - shutil.rmtree(self.dir_path) - self.dir_path = self.fb_dir - except Exception as e: - log.warning(f'unable to remove {self.dir_path}') - log.warning(e) - - def correct_datafile_name(self, line): - if ('.jpg' in line or '.png' in line) and 'noimage' not in line: - return True - return False - - def removeRelated(self, imgname): - camid = imgname[:6] - mp4s = os.listdir(os.path.join(self.dir_path, 'mp4s')) - badones = [x for x in mp4s if camid in x] - for ba in badones: - os.remove(os.path.join(self.dir_path, 'mp4s', ba)) - jpgs = os.listdir(os.path.join(self.dir_path, 'jpgs')) - badones = [x for x in jpgs if camid in x] - for ba in badones: - os.remove(os.path.join(self.dir_path, 'jpgs', ba)) - fldrs = os.listdir(self.dir_path) - badones = [x for x in fldrs if camid in x] - for ba in badones: - try: - shutil.rmtree(os.path.join(self.dir_path, ba)) - except Exception: - os.remove(os.path.join(self.dir_path, ba)) - os.remove(os.path.join(self.dir_path, 'stacks', imgname)) - - def remove_image(self): - """ Remove the selected image from disk - """ - current_image = self.listbox.get(ACTIVE) - curr_row = self.listbox.curselection() - if current_image == '': - return - if not tkMessageBox.askyesno("Delete file", f"delete {current_image}?"): - return - log.info(f'removing {current_image}') - if self.review_stack: - self.removeRelated(current_image) - else: - try: - os.remove(os.path.join(self.dir_path, 'jpgs', current_image)) - except Exception: - pass - xmlf = current_image.replace('P.jpg', '.xml') - try: - os.remove(os.path.join(self.dir_path, 'jpgs', xmlf)) - except Exception: - pass - self.selected[current_image] = (0,'') - self.update_listbox(self.get_bin_list()) - maxrows = self.listbox.index("end") - curr_row = (max(min(maxrows-1, curr_row[0]),0),) - self.listbox.select_set(curr_row) - self.listbox.activate(curr_row) - self.update_image('x') - - def update_image(self, thing): - """ When selected, load a new image - """ - try: - # Check if the list is empty. If it is, do nothing. - if self.review_stack: - self.current_image = os.path.join(self.dir_path, 'stacks', self.listbox.get(self.listbox.curselection()[0])) - elif self.soln_outputdir: - self.current_image = os.path.join(self.soln_outputdir, self.listbox.get(self.listbox.curselection()[0])) - else: - self.current_image = os.path.join(self.dir_path, 'jpgs', self.listbox.get(self.listbox.curselection()[0])) - except: - return 0 - - with img.open(self.current_image).resize((640,360)) as imgdata: - thisimage = ImageTk.PhotoImage(imgdata) - self.imagelabel.configure(image = thisimage) - self.imagelabel.image = thisimage - - self.timestamp_label.configure(text = os.path.split(self.current_image)[1]) - return - - def clean_folder(self): - stacklist = os.listdir(os.path.join(self.dir_path, 'stacks')) - camlist = [x[:6] for x in stacklist if 'stack.jpg' in x] - datalist = os.listdir(self.dir_path) - datalist = [x for x in datalist if 'jpgs' not in x and 'mp4s' not in x and 'stacks' not in x] - for d in datalist: - keep = False - for c in camlist: - if c in d: - keep = True - if keep is False: - try: - os.remove(d) - except Exception: - pass - return - - def getData(self): - thispatt = self.newpatt.get().strip() - self.patt = thispatt.ljust(15,'0') - self.dir_path = os.path.join(self.fb_dir, self.newpatt.get().strip()) - log.info(f'getting data matching {thispatt}') - os.makedirs(os.path.join(self.dir_path, 'jpgs'), exist_ok=True) - reqdate = datetime.datetime.strptime(self.patt, '%Y%m%d_%H%M%S') - reqdate = reqdate + datetime.timedelta(seconds=-30) - getLiveJpgs(reqdate.strftime('%Y%m%d_%H%M%S'), outdir=os.path.join(self.dir_path, 'jpgs')) - self.renameImages(self.dir_path) - self.update_listbox(self.get_bin_list()) - - def getTrajpickle(self): - if self.dir_path: - basepatt = os.path.split(self.dir_path)[1] - else: - basepatt = '' - fullpatt = askstring('Trajectory Name', 'eg 20240101_010203.345_UK', initialvalue=basepatt) - if not fullpatt: - return - ymd = fullpatt[:8] - trajpick = f'{fullpatt[:15]}_trajectory.pickle' - url = f'https://archive.ukmeteors.co.uk/reports/{ymd[:4]}/orbits/{ymd[:6]}/{ymd}/{fullpatt}/{trajpick}' - log.info(f'{url}') - if self.patt is None: - self.dir_path = os.path.join(self.fb_dir, fullpatt[:15]) - else: - self.dir_path = os.path.join(self.fb_dir, self.patt) - log.info(self.dir_path) - os.makedirs(self.dir_path, exist_ok=True) - get_response = requests.get(url, stream=True) - if get_response.status_code == 200: - log.info(f'retrieved {trajpick}') - with open(os.path.join(self.dir_path, trajpick), 'wb') as f: - for chunk in get_response.iter_content(chunk_size=4096): - if chunk: # filter out keep-alive new chunks - f.write(chunk) - if self.patt is None: - self.patt = fullpatt[:15] - self.newpatt.set(self.patt) - else: - log.info(f'unable to retrieve {trajpick}, {get_response.status_code}') - return - - def delOrbit(self): - orbit = None - orbit = askstring('Trajectory Name', 'eg 20240101_010203.345_UK', initialvalue='') - if not orbit: - return - tmpfname = os.path.join(self.fb_dir, f'{orbit[:15]}.delete') - open(tmpfname, 'w').write(orbit) - if self.api_key: - headers = {'Content-type': 'text/plain', 'Slug': orbit[:15], 'apikey': self.api_key} - url = f'https://api.ukmeteors.co.uk/fireballfiles?orbitfile={orbit[:15]}.delete' - r = requests.put(url, data=open(tmpfname, 'r'), headers=headers) - if r.status_code != 200: - tkMessageBox.showinfo('Warning', f'Problem with request, {r.status_code}') - else: - tkMessageBox.showinfo("Info", f'deleted {orbit}') - return - else: - tkMessageBox.showinfo('Info', "Can't delete without api key") - os.remove(tmpfname) - return - - def addImageVideo(self): - orbit = None - dirname = tkFileDialog.askdirectory(parent=root,initialdir=self.fb_dir, - title='Please select a directory') - if not dirname: - return - _, orbit = os.path.split(dirname) - ymd = orbit[:8] - pickname = f'{orbit[:15]}_trajectory.pickle' - os.makedirs(os.path.join(dirname, 'jpgs'), exist_ok=True) - os.makedirs(os.path.join(dirname, 'mp4s'), exist_ok=True) - for ext in ['jpg','mp4']: - files = glob.glob(f'{dirname}/*.{ext}') - for fil in files: - _, barename = os.path.split(fil) - shutil.move(fil, os.path.join(dirname,f'{ext}s',barename)) - url = f'https://archive.ukmeteors.co.uk/reports/{ymd[:4]}/orbits/{ymd[:6]}/{ymd[:8]}/{orbit}/{pickname}' - count = 0 - log.info(url) - get_response = requests.get(url, stream=True) - if get_response.status_code == 200: - log.info(f'retrieved {pickname}') - count += 1 - with open(os.path.join(dirname, pickname), 'wb') as f: - for chunk in get_response.iter_content(chunk_size=4096): - if chunk: # filter out keep-alive new chunks - f.write(chunk) - uploadOrbitGeneric(dirname, self.api_key) - tkMessageBox.showinfo("Info", f'Added data to {orbit}') - return - - def getVids(self): - jpglist = glob.glob1(os.path.join(self.dir_path,'jpgs'), 'FF*.jpg') - os.makedirs(os.path.join(self.dir_path, 'mp4s'), exist_ok=True) - count = 0 - for jpg in jpglist: - mp4 = jpg.replace('.jpg','.mp4') - ym = mp4[10:16] - url = f'https://archive.ukmeteors.co.uk/img/mp4/{ym[:4]}/{ym}/{mp4}' - get_response = requests.get(url, stream=True) - if get_response.status_code == 200: - log.info(f'retrieved {mp4}') - count += 1 - with open(os.path.join(self.dir_path, 'mp4s', mp4), 'wb') as f: - for chunk in get_response.iter_content(chunk_size=4096): - if chunk: # filter out keep-alive new chunks - f.write(chunk) - log.info(f'retrieved {count} videos') - tkMessageBox.showinfo("Info", f'Retrieved {count} videos') - return - - def renameImages(self, dir_path): - xmllist = glob.glob(os.path.join(dir_path,'jpgs', 'M*.xml')) - for xmlf in xmllist: - jpgf = xmlf.replace('.xml','P.jpg') - if os.path.isfile(jpgf): - xmld = xmltodict.parse(open(xmlf).read()) - realfname = xmld['ufocapture_record']['@cap'].replace('.fits','.jpg') - if not os.path.isfile(os.path.join(dir_path, 'jpgs', realfname)): - os.rename(jpgf, os.path.join(dir_path, 'jpgs', realfname)) - else: - os.remove(jpgf) - os.remove(xmlf) - return - - def viewWatchlist(self): - evtfile = os.path.join(self.fb_dir,'event_watchlist.txt') - if platform.system() == 'Darwin': # macOS - procid = subprocess.Popen(('open', evtfile)) - elif platform.system() == 'Windows': # Windows - procid = subprocess.Popen(('cmd','/c',evtfile)) - else: # linux variants - procid = subprocess.Popen(('xdg-open', evtfile)) - procid.wait() - if not tkMessageBox.askyesno("Upload File", "Upload event watchlist?"): - return - else: - self.putWatchlist() - - def getWatchlist(self): - k = paramiko.RSAKey.from_private_key_file(os.path.expanduser(self.gmn_key)) - c = paramiko.SSHClient() - server=self.gmn_server - user=self.gmn_user - log.info(f'trying {user}@{server} with {self.gmn_key}') - c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - c.connect(hostname = server, username = user, pkey = k) - scpcli = SCPClient(c.get_transport()) - log.info('getting Watchlist') - scpcli.get('./event_watchlist.txt', self.fb_dir) - self.viewWatchlist() - - def putWatchlist(self): - k = paramiko.RSAKey.from_private_key_file(os.path.expanduser(self.gmn_key)) - c = paramiko.SSHClient() - server=self.gmn_server - user=self.gmn_user - log.info(f'trying {user}@{server} with {self.gmn_key}') - c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - c.connect(hostname = server, username = user, pkey = k) - scpcli = SCPClient(c.get_transport()) - log.info('Uploading watchlist') - evtfile = os.path.join(self.fb_dir,'event_watchlist.txt') - scpcli.put(evtfile, 'event_watchlist.txt') - self.evtMonTriggered = datetime.datetime.now() + datetime.timedelta(minutes=5) - return - - def getEventData(self): - evtdate = self.newpatt.get().strip() - if len(evtdate) < 15: - tkMessageBox.showinfo("Warning", f'Need seconds in the event date field {evtdate}') - return - fbdir = self.fb_dir - if ':' in fbdir: - drv = fbdir[0].lower() - fbdir = '/mnt/' + drv + fbdir[2:] - fbdir = fbdir.replace('\\','/') - - cmd = os.path.join(self.script_loc, 'download_events.sh') + f' {evtdate} {fbdir} 1' - if ':' in cmd: - drv = cmd[0].lower() - cmd = '/mnt/' + drv + cmd[2:] - cmd = cmd.replace('\\','/') - log.info(f'executing {cmd}') - if self.evtMonTriggered is None: - ret = tkMessageBox.askyesno("Warning", 'Event Monitor has not been triggered, continue?') - if ret is False: - return - elif datetime.datetime.now() < self.evtMonTriggered: - ret = tkMessageBox.askyesno("Wait", f'Should wait till {self.evtMonTriggered.strftime("%H:%M:%S")} - continue anyway?') - if ret is False: - return - log.info(f'getting data for {evtdate}') - procid = subprocess.Popen(('bash','-c', cmd)) - procid.wait() - tkMessageBox.showinfo("Info", 'Done') - return - - def getGMNData(self): - print(self.dir_path) - camlist = [line for line in os.listdir(os.path.join(self.dir_path,'jpgs')) if self.correct_datafile_name(line)] - dts=[] - camids=[] - for cam in camlist: - camid,_ = os.path.splitext(cam) - spls = camid.split('_') - if camid[:2] == 'FF': - camids.append(spls[1]) - dts.append(camid[10:25]) - else: - camids.append(spls[-1][:6]) - dts.append(camid[1:16]) - dts.sort() - dtstr = f'{dts[0]}.000000' - stationlist = ','.join(map(str, camids)) - server=self.gmn_server - user=self.gmn_user - log.info('getting data from GMN') - k = paramiko.RSAKey.from_private_key_file(os.path.expanduser(self.gmn_key)) - c = paramiko.SSHClient() - log.info(f'trying {user}@{server} with {self.gmn_key}') - c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - c.connect(hostname = server, username = user, pkey = k) - command = f'source ~/anaconda3/etc/profile.d/conda.sh && conda activate wmpl && python scripts/extract_fireball.py {dtstr} {stationlist}' - log.info(f'running {command}') - - _, stdout, stderr = c.exec_command(command, timeout=900) - for line in iter(stdout.readline, ""): - log.info(line) - for line in iter(stderr.readline, ""): - print(line, end="") - scpcli = SCPClient(c.get_transport()) - log.info('done, collecting output') - indir = os.path.join(f'event_extract/{dtstr}/') - scpcli.get(indir, self.dir_path, recursive=True) - command = f'rm -Rf event_extract/{dtstr}' - log.info(f'running {command}') - _, stdout, stderr = c.exec_command(command, timeout=120) - for line in iter(stdout.readline, ""): - log.info(line) - for line in iter(stderr.readline, ""): - log.info(line) - dirs = os.listdir(os.path.join(self.dir_path, dtstr)) - for d in dirs: - srcdir = os.path.join(self.dir_path, dtstr, d) - targ = os.path.join(self.dir_path, d) - os.makedirs(targ, exist_ok=True) - for f in os.listdir(srcdir): - shutil.copy(os.path.join(srcdir, f), targ) - try: - shutil.rmtree(os.path.join(self.dir_path, dtstr)) - except Exception: - pass - tkMessageBox.showinfo("Data Collected", 'data collected from GMN') - self.update_listbox(self.get_bin_list()) - return - - -def uploadOrbitGeneric(orbdir, api_key): - pickles=[] - for path, _, files in os.walk(orbdir): - for name in files: - if '.pickle' in name and '_mc_' not in name and 'tmpzip' not in path: - log.info(f'adding pickle {name}') - pickles.append(os.path.join(path, name)) - if name.lower().endswith(".ecsv") and 'ecsvs' not in path: - log.info(f'copying {name}') - shutil.copyfile(os.path.join(path, name), os.path.join(orbdir, 'ecsvs', name)) - - if len(pickles) == 0: - return - pickles = list(set(pickles)) - if len(pickles) == 1: - pickfile = pickles[0] - else: - pickfile = tkFileDialog.askopenfilename(title='Select Orbit Pickle', defaultextension='*.pickle', - initialdir=orbdir, initialfile='*.pickle', - filetypes=[('pickles','*.pickle')]) - if not pickfile: - return - orbname = os.path.split(pickfile)[1] - tmpdir = os.path.join(orbdir, 'tmpzip') - os.makedirs(tmpdir, exist_ok=True) - shutil.copyfile(pickfile, os.path.join(tmpdir, orbname)) - if os.path.isdir(os.path.join(orbdir, 'jpgs')): - shutil.copytree(os.path.join(orbdir, 'jpgs'), os.path.join(tmpdir, 'jpgs'), dirs_exist_ok=True) - if os.path.isdir(os.path.join(orbdir, 'mp4s')): - shutil.copytree(os.path.join(orbdir, 'mp4s'), os.path.join(tmpdir, 'mp4s'), dirs_exist_ok=True) - if os.path.isdir(os.path.join(orbdir, 'ecsvs')): - shutil.copytree(os.path.join(orbdir, 'ecsvs'), os.path.join(tmpdir, 'ecsvs'), dirs_exist_ok=True) - for path, _, files in os.walk(orbdir): - for name in files: - if '_dyn_mass_fit' in name: - im = Image.open(os.path.join(path, name)).convert("RGB") - im.save(os.path.join(tmpdir,'jpgs', name[:-4] + '.jpg')) - zfname = os.path.join(orbdir, orbname[:15]) - shutil.make_archive(zfname,'zip',tmpdir) - try: - shutil.rmtree(tmpdir) - except Exception: - pass - - if api_key: - headers = {'Content-type': 'application/zip', 'Slug': orbname[:15], 'apikey': api_key} - url = f'https://api.ukmeteors.co.uk/fireballfiles?orbitfile={orbname[:15]}.zip' - r = requests.put(url, data=open(zfname+'.zip', 'rb'), headers=headers) #, auth=('username', 'pass')) - #print(r.text) - if r.status_code != 200: - tkMessageBox.showinfo('Warning', f'Problem with upload, {r.status_code}') - else: - tkMessageBox.showinfo('Info', 'Orbit Uploaded') - return - else: - tkMessageBox.showinfo('Info', 'Zip File created') - - -if __name__ == '__main__': - parser = argparse.ArgumentParser() - parser.add_argument("-d", "--datepatt", type=str, help="date pattern to retrieve") - args = parser.parse_args() - - dir_ = os.getcwd() - config_file = os.path.join(dir_, 'config.ini') - if not os.path.isfile(config_file): - shutil.copyfile(os.path.join(dir_, 'config.ini.sample'), config_file) - tkMessageBox.showinfo("Config Missing", 'Please configure before using') - showConfig() - - noimg_file = os.path.join(dir_, 'noimage.jpg') - - log = logging.getLogger(__name__) - log.setLevel(logging.INFO) - - logdir = os.path.join(os.getenv('TMP'), 'fbcollector') - os.makedirs(logdir, exist_ok=True) - log_file = os.path.join(logdir, log_timestamp() + '.log') - handler = logging.handlers.TimedRotatingFileHandler(log_file, when='D', interval=1) # Log to a different file each day - handler.setLevel(logging.INFO) - - formatter = logging.Formatter(fmt='%(asctime)s-%(levelname)s-%(module)s-line:%(lineno)d - %(message)s', datefmt='%Y/%m/%d %H:%M:%S') - handler.setFormatter(formatter) - log.addHandler(handler) - - ch = logging.StreamHandler(sys.stdout) - ch.setLevel(logging.DEBUG) - formatter = logging.Formatter(fmt='%(asctime)s-%(levelname)s: %(message)s', datefmt='%Y/%m/%d %H:%M:%S') - ch.setFormatter(formatter) - log.addHandler(ch) - - # Log program start - log.info("Program start") - log.info(f'config file is {config_file}') - - - # Initialize main window - root = tk.Tk() - root.geometry('+0+0') - targdir = args.datepatt - log.info(f'patt is {targdir}') - - app = fbCollector(root, patt=targdir) - root.iconbitmap(os.path.join(dir_,'ukmda.ico')) - root.protocol('WM_DELETE_WINDOW', app.quitApplication) - - root.mainloop() diff --git a/fbCollector/fireballcollector_setup.iss b/fbCollector/fireballcollector_setup.iss deleted file mode 100644 index d9cafc188..000000000 --- a/fbCollector/fireballcollector_setup.iss +++ /dev/null @@ -1,31 +0,0 @@ -#define MyAppName "UKMON Fireball Analyser" - -[Setup] -AppName=UKMON Fireball Analyser -AppVersion="1.0.0" -AppPublisher=UK Meteor Network -AppPublisherURL=https://www.ukmeteornetwork.org -DefaultDirName={localappdata}\programs\fbAnalyser -DefaultGroupName=UKMON -UninstallDisplayIcon={app}\fireballCollector.exe -Compression=lzma2 -SolidCompression=yes -OutputDir=e:\temp -OutputBaseFilename=setup_fireballCollector -PrivilegesRequired=lowest - -[Files] -Source: ".\dist\fireballCollector.exe"; DestDir: "{app}" -Source: ".\noimage.jpg"; DestDir: "{app}" -Source: ".\ukmda.ico"; DestDir: "{app}" -Source: ".\download_events.sh"; DestDir: "{app}" -Source: ".\config.ini.sample"; DestDir: "{app}"; Permissions: users-modify -Source: ".\README.html"; DestDir: "{app}" - -[Tasks] -Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; - -[Icons] -Name: "{group}\Fireball Analyser"; Filename: "{app}\fireballCollector.exe"; IconFilename: "{app}\ukmda.ico" -Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" -Name: "{userdesktop}\{#MyAppName}"; Filename: "{app}\fireballCollector.exe"; Tasks: desktopicon; WorkingDir: {app}; IconFilename: "{app}\ukmda.ico" diff --git a/fbCollector/noimage.jpg b/fbCollector/noimage.jpg deleted file mode 100644 index 80e1f661b5026d1638879487ba8e3cc998297c6e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 70608 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%0AV0n{t4Wm? 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 diff --git a/fbCollector/requirements.txt b/fbCollector/requirements.txt deleted file mode 100644 index 124471433..000000000 --- a/fbCollector/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -# requirements for the fbCollector tool -# Copyright (C) 2018-2023 Mark McIntyre -Pillow -paramiko -scp -pyqtgraph -xmltodict -requests -pandas -markdown2 \ No newline at end of file diff --git a/fbCollector/ukmda.ico b/fbCollector/ukmda.ico deleted file mode 100644 index e788188d8dc5d1b3e5284da0e908c9830808bbac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16958 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 diff --git a/usermgmt/windows/release_notes.md b/usermgmt/windows/release_notes.md deleted file mode 100644 index 979fd1c87..000000000 --- a/usermgmt/windows/release_notes.md +++ /dev/null @@ -1,19 +0,0 @@ -# The UKMON Station Management Tool - -This application is used to add, modify and disable RMS stations linked to UKMON. The app is intended for use only by the UKMON committee members responsible for managing users. - -## Before Installation -First you should contact me, Mark McIntyre so we can set up a userid for you. - -## Installation -Download the zip file and expand it to a location of your choice, for example `c:\programdata\ukmon` -Create an SSH keypair. -Edit the config file and add the userid i provided, plus the full path to the ssh private key. -Save the config file and run the app. - -## Use -The menus should be pretty self-explanatory. - -## Release Notes -2024.04.2 - first release. -2025.10.1 - overhaul to fix a plethora of bugs and add new features. \ No newline at end of file diff --git a/usermgmt/windows/requirements.txt b/usermgmt/windows/requirements.txt deleted file mode 100644 index 3894e9b5f..000000000 --- a/usermgmt/windows/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (C) 2018-2023 Mark McIntyre -boto3 -openpyxl -pandas -scp -tksheet -paramiko -pyinstaller diff --git a/usermgmt/windows/stationMaint.ps1 b/usermgmt/windows/stationMaint.ps1 deleted file mode 100644 index 23b12ae38..000000000 --- a/usermgmt/windows/stationMaint.ps1 +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright (C) 2018-2023 Mark McIntyre -push-Location $psscriptroot - -# create conda env if not aleady there -$isadm=(conda env list | select-string "ukmon-admin") -if ($isadm.count -eq 0) { - conda create -n ukmon-admin python=3.11 - pip install -r requirements.txt -} -conda activate ukmon-admin - -bash -c "rsync -avz --delete ukmonhelper2:keymgmt/sshkeys/ ./sshkeys" -bash -c "rsync -avz --delete ukmonhelper2:keymgmt/keys/ ./keys" -bash -c "rsync -avz --delete ukmonhelper2:keymgmt/csvkeys/ ./csvkeys" -bash -c "rsync -avz --delete ukmonhelper2:keymgmt/inifs/ ./inifs" - -python stationMaint2.py -Pop-Location \ No newline at end of file diff --git a/usermgmt/windows/stationMaint2.py b/usermgmt/windows/stationMaint2.py deleted file mode 100644 index 7da203ff0..000000000 --- a/usermgmt/windows/stationMaint2.py +++ /dev/null @@ -1,969 +0,0 @@ -# Copyright (C) 2018-2023 Mark McIntyre - -from tksheet import Sheet -import tkinter as tk -from tkinter import ttk -from tkinter import Frame, Menu -from tkinter import simpledialog -import tkinter.messagebox as tkMessageBox -from tkinter.filedialog import askopenfilename -import boto3 -import os -import sys -import paramiko -import json -import time -import datetime -import tempfile -from scp import SCPClient -from boto3.dynamodb.conditions import Key -import pandas as pd -from configparser import ConfigParser -import logging -import logging.handlers -import shutil -import platform -import subprocess - - -log = logging.getLogger("logger") - -config_file = '' - - -def loadConfig(cfgdir): - config_file = os.path.join(cfgdir, 'stationmaint.ini') - if not os.path.isfile(config_file): - tkMessageBox.showinfo("Config Missing", 'Please configure before using') - shutil.copyfile(f'{config_file}.sample', config_file) - if platform.system() == 'Darwin': # macOS - procid = subprocess.Popen(('open', config_file)) - elif platform.system() == 'Windows': # Windows - procid = subprocess.Popen(('cmd','/c',config_file)) - else: # linux variants - procid = subprocess.Popen(('xdg-open', config_file)) - procid.wait() - cfg = ConfigParser() - cfg.read(config_file) - server = cfg['helper']['helperip'] - user = cfg['helper']['user'] - keyfile = cfg['helper']['sshkey'] - k = paramiko.RSAKey.from_private_key_file(os.path.expanduser(keyfile)) - c = paramiko.SSHClient() - c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - c.connect(hostname = server, username = user, pkey = k) - scpcli = c.open_sftp() - try: - handle, tmpfnam = tempfile.mkstemp() - scpcli.get(f'{user}.csv', tmpfnam) - except Exception: - log.warning('unable to find AWS key') - scpcli.close() - c.close() - try: - lis = open(tmpfnam, 'r').readlines() - os.close(handle) - os.remove(tmpfnam) - key, sec = lis[1].split(',') - except Exception: - log.warning('malformed AWS key') - if key: - log.info('retrieved key details') - awskeys = {'key': key.strip(), 'secret': sec.strip()} - else: - awskeys = {} - return cfg, awskeys - - -def addRow(newdata=None, stationid=None, site=None, user=None, email=None, ddb=None, - direction=None, camtype=None, active=None, createdate=None, tblname='camdetails'): - ''' - add a row to the CamDetails table - ''' - if not ddb: - ddb = boto3.resource('dynamodb', region_name='eu-west-2') - if not newdata: - newdata = {'stationid': stationid, 'site': site, 'humanName':user, 'eMail': email, - 'direction': direction, 'camtype': camtype, 'active': active, 'created': createdate, - 'oldcode': stationid} - table = ddb.Table(tblname) - response = table.put_item(Item=newdata) - if response['ResponseMetadata']['HTTPStatusCode'] != 200: - log.info(response) - return - - -def loadLocationDetails(table='camdetails', ddb=None, loadall=False): - if not ddb: - ddb = boto3.resource('dynamodb', region_name='eu-west-2') - table = ddb.Table(table) - res = table.scan() - # strictly, should check for LastEvaluatedKey here, in case there was more than 1MB of data, - # however that equates to around 30,000 users which i hope we never have... - values = res.get('Items', []) - camdets = pd.DataFrame(values) - camdets.sort_values(by=['stationid'], inplace=True) - if not loadall: - camdets.dropna(inplace=True, subset=['eMail','humanName','site']) - camdets['camtype'] = camdets['camtype'].astype(int) - camdets['active'] = camdets['active'].astype(int) - return camdets - - -def findLocationInfo(srchstring, ddb=None, statdets=None): - if statdets is None: - statdets = loadLocationDetails(ddb=ddb) - statdets = statdets[statdets.active==1] - s1 = statdets[statdets.stationid.str.contains(srchstring)] - s2 = statdets[statdets.eMail.str.contains(srchstring)] - s3 = statdets[statdets.humanName.str.contains(srchstring)] - s4 = statdets[statdets.site.str.contains(srchstring)] - srchres = pd.concat([s1, s2, s3, s4]) - srchres.drop(columns=['oldcode','active','camtype'], inplace=True) - return srchres - - -def cameraExists(stationid=None, location=None, direction=None, ddb=None, statdets=None): - if statdets is None: - statdets = loadLocationDetails(ddb=ddb) - statdets = statdets[statdets.active==1] - if stationid: - if len(statdets[statdets.stationid == stationid]) > 0: - return True - if location and direction: - s1 = statdets[statdets.site == location] - if len(s1) > 0: - if len(s1[s1.direction == direction]) > 0: - return True - return False - - -def dumpCamTable(outdir, statdets=None, ddb=None, exportmindets=False): - if statdets is None: - statdets = loadLocationDetails(ddb=ddb) - statdets = statdets[statdets.active==1] - if exportmindets: - statdets = statdets[['stationid', 'eMail']] - statdets.to_csv(os.path.join(outdir,'camtable.csv'), index=False) - - -def getCamUpdateDate(camid, ddb=None): - if not ddb: - ddb = boto3.resource('dynamodb', region_name='eu-west-2') - table = ddb.Table('LiveBrightness') - resp = table.query(KeyConditionExpression=Key('camid').eq(camid), - IndexName = 'camid-CaptureNight-index', - ScanIndexForward=False, - Limit=1, - Select='SPECIFIC_ATTRIBUTES', - ProjectionExpression='CaptureNight') - if len(resp['Items']) > 0: - return int(resp['Items'][0]['CaptureNight']) - else: - return 0 - - -class srcResBox(tk.Toplevel): - ''' - A class for the search dialog - ''' - def __init__(self, title, message): - tk.Toplevel.__init__(self) - self.details_expanded = False - self.title(title) - self.geometry('600x250') - self.minsize(700, 250) - self.maxsize(700, 550) - self.rowconfigure(0, weight=0) - self.rowconfigure(1, weight=1) - self.columnconfigure(0, weight=1) - - button_frame = tk.Frame(self) - button_frame.grid(row=0, column=0, sticky='nsew') - button_frame.columnconfigure(0, weight=1) - button_frame.columnconfigure(1, weight=1) - - text_frame = tk.Frame(self) - text_frame.grid(row=1, column=0, padx=(7, 7), pady=(7, 7), sticky='nsew') - text_frame.rowconfigure(0, weight=1) - text_frame.columnconfigure(0, weight=1) - - self.textbox = tk.Text(text_frame, height=6) - self.textbox.insert('1.0', message) - self.textbox.grid(row=0, column=0, sticky='nsew') - self.geometry('700x550') - self.details_expanded = True - - -class infoDialog(simpledialog.Dialog): - ''' - A class to gather or display info on a camera - ''' - def __init__(self, parent, title, location, user, email, sshkey='', id=''): - self.data = [] - self.data.append(id) - self.data.append(location) - self.data.append('') - self.data.append(user) - self.data.append(email) - self.data.append(sshkey) - self.parent = parent - - super().__init__(parent, title) - - def body(self, frame): - self.camid_label = tk.Label(frame, width=25, text="RMS ID") - self.camid_label.pack() - self.camid_box = tk.Entry(frame, width=25) - self.camid_box.insert(tk.END, self.data[0]) - self.camid_box.pack() - - self.location_label = tk.Label(frame, width=25, text="Location") - self.location_label.pack() - self.location_box = tk.Entry(frame, width=25) - self.location_box.insert(tk.END, self.data[1]) - self.location_box.pack() - - self.direction_label = tk.Label(frame, width=25, text="direction") - self.direction_label.pack() - self.direction_box = tk.Entry(frame, width=25) - self.direction_box.pack() - - self.ownername_label = tk.Label(frame, width=25, text="owner name") - self.ownername_label.pack() - self.ownername_box = tk.Entry(frame, width=25) - self.ownername_box.insert(tk.END, self.data[3]) - self.ownername_box.pack() - - self.email_label = tk.Label(frame, width=25, text="email address") - self.email_label.pack() - self.email_box = tk.Entry(frame, width=25) - self.email_box.insert(tk.END, self.data[4]) - self.email_box.pack() - - self.sshkey_label = tk.Label(frame, width=25, text="SSH key") - self.sshkey_label.pack() - self.sshkey_box = tk.Entry(frame, width=50) - self.sshkey_box.insert(tk.END, self.data[5]) - self.sshkey_box.pack() - - def ok_pressed(self): - self.data[0] = self.camid_box.get().strip() - self.data[1] = self.location_box.get().strip().capitalize() - self.data[2] = self.direction_box.get().strip().upper() - self.data[3] = self.ownername_box.get().strip() - self.data[4] = self.email_box.get().strip() - self.data[5] = self.sshkey_box.get().strip() - if cameraExists(location=self.data[1],direction=self.data[2], statdets=self.parent.stationdetails): - msg = f'{self.data[1]}_{self.data[2]} already exists' - tk.messagebox.showinfo(title="Information", message=msg) - else: - self.destroy() - - def cancel_pressed(self): - self.data[0] = '' - self.destroy() - - def buttonbox(self): - self.ok_button = tk.Button(self, text='OK', width=5, command=self.ok_pressed) - self.ok_button.pack(side="left") - cancel_button = tk.Button(self, text='Cancel', width=5, command=self.cancel_pressed) - cancel_button.pack(side="right") - self.bind("", lambda event: self.ok_pressed()) - self.bind("", lambda event: self.cancel_pressed()) - - -class statOwnerDialog(simpledialog.Dialog): - ''' - A class to display station ownership data - ''' - def __init__(self, parent): - self.stationdetails = parent.stationdetails - self.parent = parent - super().__init__(parent, 'Owner Info') - - def body(self, frame): - columns = ('#1','#2','#3','#4') - tree = ttk.Treeview(frame, columns=columns, show='headings') - tree.heading('#1', text='camid') - tree.heading('#2', text='site') - tree.heading('#3', text='email') - tree.heading('#4', text='humanName') - contacts = [] - for _, li in self.stationdetails.iterrows(): - contacts.append((li['stationid'], li['site'], li['eMail'], li['humanName'])) - for contact in contacts: - tree.insert('', tk.END, values=contact) - tree.grid(row=0, column=0, sticky='nsew') - scrollbar = ttk.Scrollbar(root, orient=tk.VERTICAL, command=tree.yview) - tree.configure(yscroll=scrollbar.set) - scrollbar.grid(row=0, column=1, sticky='ns') - return - - -class CamMaintenance(Frame): - ''' - The main camera maintenance window class - ''' - def __init__(self, parent, cfgdir): - self.parent = parent - Frame.__init__(self, parent) - - self.config_dir = cfgdir - self.cfg, awskeys = loadConfig(cfgdir) - self.conn = boto3.Session(aws_access_key_id=awskeys['key'], aws_secret_access_key=awskeys['secret']) - self.bucket_name = self.cfg['store']['srcbucket'] - - os.makedirs('keys', exist_ok=True) - os.makedirs('jsonkeys', exist_ok=True) - os.makedirs('csvkeys', exist_ok=True) - os.makedirs('users', exist_ok=True) - os.makedirs('inifs', exist_ok=True) - os.makedirs('sshkeys', exist_ok=True) - - self.ddb = self.conn.resource('dynamodb', region_name='eu-west-2') - try: - self.stationdetails = loadLocationDetails(ddb=self.ddb) - except Exception: - log.info('unable to get operator details - probably wrong AWS profile') - exit(1) - tmpdf = self.stationdetails[['site','stationid','direction','camtype','active','humanName','eMail','oldcode','created']] - tmpdf = tmpdf.sort_values(by=['active','site','stationid'], ascending=[True,True,True]) - self.data = tmpdf.values.tolist() - self.hdrs = tmpdf.columns.tolist() - - self.parent.title('Station Maintenance') - - # Make menu - self.menuBar = Menu(self.parent) - self.parent.config(menu=self.menuBar) - - # File menu - fileMenu = Menu(self.menuBar, tearoff=0) - fileMenu.add_command(label="Exit", command=self.on_closing) - - camMenu = Menu(self.menuBar, tearoff=0) - camMenu.add_command(label = "Add Camera", command = self.addCamera) - camMenu.add_command(label = "Relocate Camera", command = self.moveCamera) - camMenu.add_separator() - camMenu.add_command(label = "Check Camera", command = self.checkLastUpdate) - camMenu.add_separator() - camMenu.add_command(label = "Download Platepar", command = self.getPlate) - camMenu.add_command(label = "Update Platepar", command = self.newPlate) - camMenu.add_separator() - camMenu.add_command(label = "Update SSH Key", command = self.newSSHKey) - camMenu.add_command(label = "Update AWS Key", command = self.newAWSKey) - - ownMenu = Menu(self.menuBar, tearoff=0) - ownMenu.add_command(label = "View Owner Data", command = self.viewOwnerData) - ownMenu.add_command(label = "Search Data", command = self.searchOwnerData) - - helpMenu = Menu(self.menuBar, tearoff=0) - helpMenu.add_command(label = "Documentation", command = self.viewReadme) - helpMenu.add_command(label = "About", command = self.aboutBox) - - self.menuBar.add_cascade(label="File", underline=0, menu=fileMenu) - self.menuBar.add_cascade(label="Camera", underline=0, menu=camMenu) - self.menuBar.add_cascade(label="Owners", underline=0, menu=ownMenu) - self.menuBar.add_cascade(label="Help", underline=0, menu=helpMenu) - - parent.grid_columnconfigure(0, weight = 1) - parent.grid_rowconfigure(0, weight = 1) - self.frame = tk.Frame(self) - - self.frame.grid_columnconfigure(0, weight = 1) - self.frame.grid_rowconfigure(0, weight = 1) - self.sheet = Sheet(self.parent, - page_up_down_select_row = True, - expand_sheet_if_paste_too_big = True, - #empty_vertical = 0, - column_width = 120, - startup_select = (0,1,"rows"), - data = self.data, - headers = self.hdrs, - height = 700, - width = 700) - - self.sheet.enable_bindings(("single_select", - "drag_select", - "select_all", - "column_width_resize", - "double_click_column_resize", - "row_width_resize", - "column_height_resize", - "arrowkeys", - "row_height_resize", - "double_click_row_resize", - "right_click_popup_menu", - "rc_delete_row", - "copy", - "cut", - "paste", - "delete", - "undo", - "edit_cell" - )) - self.sheet.popup_menu_add_command("Sort by this Column", self.columns_sort) - - self.frame.grid(row = 1, column = 0, sticky = "nswe") - self.sheet.grid(row = 0, column = 0, sticky = "nswe") - - self.sheet.change_theme("light green") - - self.sheet.set_all_column_widths() - - #self.sheet.extra_bindings("end_delete_rows", self.end_delete_rows) - #self.sheet.extra_bindings("column_select", self.column_select) - self.sheet.extra_bindings([("all_select_events", self.all_extra_bindings), - ("begin_edit_cell", self.begin_edit_cell), - ("end_edit_cell", self.end_edit_cell)]) - - self.sheet.popup_menu_add_command('Sort', self.columns_sort, table_menu = False, index_menu = False) - self.parent.title('Please wait, syncing data...') - self.resyncLocalFiles() - self.parent.title('Station Maintenance') - return - - def hide_columns_right_click(self, event = None): - currently_displayed = self.sheet.display_columns() - exclude = set(currently_displayed[c] for c in self.sheet.get_selected_columns()) - indexes = [c for c in currently_displayed if c not in exclude] - self.sheet.display_columns(indexes = indexes, enable = True, refresh = True) - - def all_extra_bindings(self, event): - pass - - def begin_edit_cell(self, event): - self.oldval = self.data[event[0]][event[1]] - return self.oldval - - def end_edit_cell(self, event): - #log.info(event) - if event[3] != self.oldval: - data = self.data[event[0]] - data[event[1]] = event[3] - newdata = {'stationid': data[1], 'site': data[0], 'humanName':data[5], 'eMail': data[6], - 'direction': data[2], 'camtype': str(data[3]), 'active': int(data[4]), 'oldcode': data[1], - 'created': data[8]} - addRow(newdata, ddb=self.ddb) - if int(data[4]) > 1: - self.disableEnableUnixUser(data[0], data[2], False) - else: - self.disableEnableUnixUser(data[0], data[2], True) - return event[3] - - def end_delete_rows(self, event): - pass - - def window_resized(self, event): - pass - - def mouse_motion(self, event): - pass - - def deselect(self, event): - log.info(f'{event} {self.sheet.get_selected_cells()}') - - def rc(self, event): - log.info(event) - - def cell_select(self, response): - pass - - def shift_select_cells(self, response): - pass - - def drag_select_cells(self, response): - pass - - def ctrl_a(self, response): - pass - - def row_select(self, response): - pass - - def shift_select_rows(self, response): - log.info(response) - - def drag_select_rows(self, response): - pass - - def columns_sort(self): - cursel = self.sheet.get_selected_cells() - col = list(cursel)[0][1] - log.info(self.hdrs[col]) - pass - - def column_select(self, response): - pass - - def shift_select_columns(self, response): - pass - - def drag_select_columns(self, response): - pass - - def on_closing(self): - outdir = 'stationdetails' - os.makedirs(outdir, exist_ok=True) - dumpCamTable(outdir=outdir, statdets=self.stationdetails, exportmindets=False) - log.info('quitting') - self.destroy() - self.parent.quit() - self.parent.destroy() - - def viewOwnerData(self): - statOwnerDialog(self) - return - - def searchOwnerData(self): - srchstring = simpledialog.askstring("Some_Name", "Search String",parent=root) - srchres = findLocationInfo(srchstring, statdets=self.stationdetails) - msgtext = '' - for _, li in srchres.iterrows(): - msgtext = msgtext + f'{li.stationid:10s}{li.site:20s}{li.eMail:30s}{li.humanName:20s}\n' - srcResBox(title="Results", message=msgtext) - return - - def moveCamera(self): - self.addCopyCamera(move=True) - return - - def checkLastUpdate(self): - cursel = self.sheet.get_selected_cells() - cr = list(cursel)[0][0] - curdata = self.data[cr] - camid = curdata[1] - lastupd = getCamUpdateDate(camid, ddb=self.ddb) - msg = f'{camid} last sent a live image on {lastupd}' - tk.messagebox.showinfo(title="Information", message=msg) - return - - def addCamera(self): - self.addCopyCamera(move=False) - return - - def addCopyCamera(self, move=False): - cursel = self.sheet.get_selected_cells() - cr = list(cursel)[0][0] - curdata = self.data[cr] - user = curdata[5] - email = curdata[6] - if move is True: - sshkey = self.getSSHkey(curdata[0], curdata[2]) - id = curdata[1] - title = 'Move Camera' - oldloc = curdata[0].lower() + '_' + curdata[2].lower() - created = curdata[7] - else: - sshkey = '' - id = '' - title = 'Add Camera' - oldloc = '' - created = datetime.datetime.now().strftime('%Y%m%d') - answer = infoDialog(self, title, curdata[0], user, email, sshkey, id) - if answer.data[0].strip() != '': - d = answer.data - rmsid = str(d[0]).upper() - location = str(d[1]).capitalize() - cameraname = d[1].lower() + '_' + d[2].lower() - with open(os.path.join('sshkeys', cameraname + '.pub'), 'w') as outf: - outf.write(d[5]) - rowdata=[d[1],d[0],d[2],'2','1',d[3],d[4],d[0]] - self.sheet.insert_row(values=rowdata, idx=0) - self.addNewAwsUser(location) - self.createIniFile(cameraname) - self.addNewUnixUser(location, cameraname, oldloc) - self.addNewOwner(rmsid, location, str(d[3]), str(d[4]), str(d[2]), '2','1', created) - log.info('done') - return - - def newSSHKey(self): - cursel = self.sheet.get_selected_cells() - cr = list(cursel)[0][0] - curdata = self.data[cr] - user,email = self.getUserDetails(self.stationdetails, curdata[1]) - sshkey = '' - id = '' - title = 'Update SSH Key' - answer = infoDialog(self, title, curdata[0], user, email, sshkey, id) - if answer.data[0].strip() != '': - d = answer.data - location = str(d[1]).capitalize() - cameraname = d[1].lower() + '_' + d[2].lower() - with open(os.path.join('sshkeys', cameraname + '.pub'), 'w') as outf: - outf.write(d[5]) - self.addNewUnixUser(location, cameraname, updatemode=2) - self.datachanged = True - return - - def getPlate(self): - cursel = self.sheet.get_selected_cells() - cr = list(cursel)[0][0] - curdata = self.data[cr] - ppdir = self.cfg['helper']['platepardir'] - ppdir = os.path.join(ppdir, curdata[1]) - os.makedirs(ppdir, exist_ok=True) - ppfile = 'platepar_cmn2010.cal' - site = curdata[0].capitalize() - camid = curdata[1].upper() - - server = self.cfg['helper']['helperip'] - user='ec2-user' - keyfile = self.cfg['helper']['sshkey'] - k = paramiko.RSAKey.from_private_key_file(os.path.expanduser(keyfile)) - c = paramiko.SSHClient() - c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - c.connect(hostname = server, username = user, pkey = k) - scpcli = SCPClient(c.get_transport()) - remotedir = self.cfg['helper']['remotedir'] - remotef=f'{remotedir}/consolidated/platepars/{camid}.json' - localf = os.path.join(ppdir, ppfile) - scpcli.get(remotef, localf) - scpcli.close() - c.close() - - s3 = boto3.client('s3') - res = s3.list_objects_v2(Bucket=self.bucket_name, Prefix=f'archive/{site}/{camid}/2023/202312/') - if res['KeyCount'] > 0: - keys=res['Contents'] - fitsfiles = [x['Key'] for x in keys if 'fits' in x['Key']] - fitsfiles.sort() - fitsfiles = fitsfiles[-10:] - for ff in fitsfiles: - _, ffname = os.path.split(ff) - dlf = os.path.join(ppdir, ffname) - s3.download_file(self.bucket_name, ff, dlf) - cfgfiles = [x['Key'] for x in keys if '.config' in x['Key']] - cfgfiles.sort() - dlf = os.path.join(ppdir,'.config') - s3.download_file(self.bucket_name, cfgfiles[-1], dlf) - return - - def newPlate(self): - cursel = self.sheet.get_selected_cells() - cr = list(cursel)[0][0] - curdata = self.data[cr] - ppdir =self.cfg['helper']['platepardir'] - ppdir = os.path.join(ppdir, curdata[1]) - ppfile = 'platepar_cmn2010.cal' - plate = '' - title = 'Select New Platepar File' - plate = askopenfilename(title=title, defaultextension='*.cal',initialdir=ppdir, initialfile=ppfile) - if plate: - self.uploadPlatepar(curdata, plate) - log.info(plate) - return - - def resyncLocalFiles(self): - server = self.cfg['helper']['helperip'] - user='ec2-user' - keyfile = self.cfg['helper']['sshkey'] - k = paramiko.RSAKey.from_private_key_file(os.path.expanduser(keyfile)) - c = paramiko.SSHClient() - c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - c.connect(hostname = server, username = user, pkey = k) - scpcli = c.open_sftp() - for fldr in ['csvkeys','inifs','keys','sshkeys']: - targdir = f'/home/{user}/keymgmt/{fldr}' - flist = scpcli.listdir_attr(targdir) - for fil in flist: - copyme = True - fname = fil.filename - localfname = os.path.join(fldr, fname) - if os.path.isfile(localfname): - mtime = os.path.getmtime(localfname) - if fil.st_mtime - mtime > 1: - copyme = True - else: - copyme = False - if copyme: - scpcli.get(f'{targdir}/{fname}', localfname) - print(f'Updated {fname}') - scpcli.close() - c.close() - - return - - def newAWSKey(self): - cursel = self.sheet.get_selected_cells() - cr = list(cursel)[0][0] - curdata = self.data[cr] - location = curdata[0] - self.createNewAwsKey(location, self.stationdetails) - - def uploadPlatepar(self, camdets, plateparfile): - server = self.cfg['helper']['helperip'] - user='ec2-user' - uplfile = f'/tmp/platepar_cmn2010_{camdets[1]}.cal' - camname = f'{camdets[0]}_{camdets[3]}'.lower() - keyfile = self.cfg['helper']['sshkey'] - k = paramiko.RSAKey.from_private_key_file(os.path.expanduser(keyfile)) - c = paramiko.SSHClient() - c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - c.connect(hostname = server, username = user, pkey = k) - scpcli = SCPClient(c.get_transport()) - scpcli.put(plateparfile, uplfile) - command = f'sudo mkdir -p /var/sftp/{camname}/platepar/ && sudo chown {camname}:{camname} /var/sftp/{camname}/platepar' - log.info(f'running {command}') - _, stdout, stderr = c.exec_command(command, timeout=10) - for line in iter(stdout.readline, ""): - log.info(line) - for line in iter(stderr.readline, ""): - log.info(line) - command = f'sudo mv {uplfile} /var/sftp/{camname}/platepar/platepar_cmn2010.cal' - log.info(f'running {command}') - _, stdout, stderr = c.exec_command(command, timeout=10) - for line in iter(stdout.readline, ""): - log.info(line) - for line in iter(stderr.readline, ""): - log.info(line) - command = f'sudo chown {camname}:{camname} /var/sftp/{camname}/platepar/platepar_cmn2010.cal' - log.info(f'running {command}') - _, stdout, stderr = c.exec_command(command, timeout=10) - for line in iter(stdout.readline, ""): - log.info(line) - for line in iter(stderr.readline, ""): - log.info(line) - scpcli.close() - c.close() - return - - def addNewOwner(self, rmsid, location, user, email, direction, camtype, active, created): - log.info(f'adding new owner {user} with {email} for {rmsid} at {location}') - newdata = {'stationid': rmsid, 'site': location, 'humanName':user, 'eMail': email, - 'direction': direction, 'camtype': camtype, 'active': int(active), 'oldcode': rmsid, - 'created': created} - addRow(newdata=newdata, ddb=self.ddb) - return - - def addNewAwsUser(self, location): - log.info(f'adding new location {location} to AWS') - archkeyf = 'jsonkeys/' + location + '.key' - archuserdets = 'users/' + location + '.txt' - archcsvf = os.path.join('csvkeys', location + '.csv') - os.makedirs('jsonkeys', exist_ok=True) - os.makedirs('csvkeys', exist_ok=True) - os.makedirs('users', exist_ok=True) - - iamc = self.conn.client('iam') - try: - _ = iamc.get_user(UserName=location) - log.info('location exists, not adding it') - archkey = None - except iamc.exceptions.NoSuchEntityException: - log.info('new location') - usr = iamc.create_user(UserName=location) - with open(archuserdets, 'w') as outf: - outf.write(str(usr)) - archkey = iamc.create_access_key(UserName=location) - with open(archkeyf, 'w') as outf: - outf.write(json.dumps(archkey, indent=4, sort_keys=True, default=str)) - with open(archcsvf,'w') as outf: - outf.write('Access key ID,Secret access key\n') - outf.write('{},{}\n'.format(archkey['AccessKey']['AccessKeyId'], archkey['AccessKey']['SecretAccessKey'])) - _ = iamc.add_user_to_group(GroupName='cameras', UserName=location) - if archkey is not None: - self.createKeyFile(location) - return - - def createNewAwsKey(self, location, caminfo): - server = self.cfg['helper']['helperip'] - user='ec2-user' - keyfile = self.cfg['helper']['sshkey'] - k = paramiko.RSAKey.from_private_key_file(os.path.expanduser(keyfile)) - c = paramiko.SSHClient() - c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - c.connect(hostname = server, username = user, pkey = k) - command = f'/home/{user}/keymgmt/updateAwsKey.sh {location} force' - log.info(f'running {command}') - _, stdout, stderr = c.exec_command(command, timeout=60) - for line in iter(stderr.readline, ""): - log.info(line) - for line in iter(stdout.readline, ""): - log.info(line) - tkMessageBox.showinfo('Updated', line.split(' to')[0]) - log.info('done') - c.close() - return - - - def getSSHkey(self, loc, dir): - server= self.cfg['helper']['helperip'] - user='ec2-user' - tmpdir=os.getenv('TEMP', default='c:/temp') - cameraname = (loc + '_' + dir).lower() - keyfile = self.cfg['helper']['sshkey'] - k = paramiko.RSAKey.from_private_key_file(os.path.expanduser(keyfile)) - c = paramiko.SSHClient() - c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - c.connect(hostname = server, username = user, pkey = k) - scpcli = SCPClient(c.get_transport()) - tmpfil = os.path.join(tmpdir,'./tmp.txt') - # dont use os.path.join - source is on unix we are on windows! - scpcli.get(f'keymgmt/sshkeys/{cameraname}.pub', tmpfil) - scpcli.close() - c.close() - - with open(tmpfil, 'r') as inf: - lis = inf.readlines() - #os.remove('./tmp.txt') - return lis[0].strip() - - - def getUserDetails(self, stationdetails, camid): - reqdf = stationdetails[stationdetails.stationid == camid] - if len(reqdf) == 0: - return '','' - return reqdf.eMail.iloc[0], reqdf.humanName.iloc[0] - - - def addNewUnixUser(self, location, cameraname, oldcamname='', updatemode=0): - server = self.cfg['helper']['helperip'] - user='ec2-user' - log.info(f'adding new Unix user {cameraname}') - keyfile = self.cfg['helper']['sshkey'] - k = paramiko.RSAKey.from_private_key_file(os.path.expanduser(keyfile)) - c = paramiko.SSHClient() - c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - try: - c.connect(hostname = server, username = user, pkey = k) - except Exception: - c.connect(hostname = server+'.', username = user, pkey = k) - scpcli = SCPClient(c.get_transport()) - scpcli.put(os.path.join('sshkeys', cameraname + '.pub'), 'keymgmt/sshkeys/') - scpcli.put(os.path.join('keys', location.lower() + '.key'), 'keymgmt/keys/') - scpcli.put(os.path.join('csvkeys', location + '.csv'), 'keymgmt/csvkeys/') - scpcli.put(os.path.join('inifs', cameraname + '.ini'), 'keymgmt/inifs/') - command = f'/home/{user}/keymgmt/addSftpUser.sh {cameraname} {location} {updatemode} {oldcamname}' - log.info(f'running {command}') - _, stdout, stderr = c.exec_command(command, timeout=60) - for line in iter(stdout.readline, ""): - log.info(line) - for line in iter(stderr.readline, ""): - log.info(line) - - log.info('done, collecting output') - infname = os.path.join('keymgmt/inifs/',cameraname + '.ini') - outfname = os.path.join('./inifs', cameraname + '.ini') - while os.path.isfile(outfname) is False: - try: - time.sleep(3) - scpcli.get(infname, outfname) - except Exception: - continue - scpcli.close() - c.close() - return - - def disableEnableUnixUser(self, loc, dir, enable): - cameraname = loc.lower() + '_' + dir.lower() - server = self.cfg['helper']['helperip'] - user='ec2-user' - log.info(f'updating Unix user {cameraname}') - keyfile = self.cfg['helper']['sshkey'] - k = paramiko.RSAKey.from_private_key_file(os.path.expanduser(keyfile)) - c = paramiko.SSHClient() - c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - try: - c.connect(hostname = server, username = user, pkey = k) - except Exception: - c.connect(hostname = server+'.', username = user, pkey = k) - if enable is False: - command = f'/usr/bin/sudo /usr/sbin/usermod --expiredate 1 {cameraname}' - else: - command = f'/usr/bin/sudo /usr/sbin/usermod --expiredate "" {cameraname}' - log.info(f'running {command}') - line = '' - _, stdout, stderr = c.exec_command(command, timeout=60) - for line in iter(stderr.readline, ""): - log.info(line) - for line in iter(stdout.readline, ""): - log.info(line) - if enable is False: - tkMessageBox.showinfo('Disabled', f'{cameraname} {line}') - return - - def createKeyFile(self, location): - archbucket = self.cfg['store']['srcbucket'] - livebucket = self.cfg['store']['livebucket'] - webbucket = self.cfg['store']['websitebucket'] - - os.makedirs('keys', exist_ok=True) - outf = 'keys/' + location.lower() + '.key' - with open(outf, 'w') as ouf: - ouf.write('export AWS_DEFAULT_REGION=eu-west-1\n') - ouf.write(f'export CAMLOC="{location}"\n') - ouf.write(f'export S3FOLDER="archive/{location}/"\n') - ouf.write(f'export ARCHBUCKET={archbucket}\n') - ouf.write(f'export LIVEBUCKET={livebucket}\n') - ouf.write(f'export WEBBUCKET={webbucket}\n') - ouf.write('export ARCHREGION=eu-west-2\n') - ouf.write('export LIVEREGION=eu-west-1\n') - ouf.write('export MATCHDIR=matches/RMSCorrelate\n') - return - - - def createIniFile(self, cameraname): - helperip = self.cfg['helper']['helperip'] - os.makedirs('inifs', exist_ok=True) - outf = 'inifs/' + cameraname + '.ini' - with open(outf, 'w') as outf: - outf.write('# config data for this station\n') - outf.write(f'export LOCATION={cameraname}\n') - outf.write(f'export UKMONHELPER={helperip}\n') - outf.write('export UKMONKEY=~/.ssh/ukmon\n') - outf.write('export RMSCFG=~/source/RMS/.config\n') - return - - def viewReadme(self): - docfile = os.path.join(self.config_dir, 'README.md') - if platform.system() == 'Darwin': # macOS - procid = subprocess.Popen(('open', docfile)) - elif platform.system() == 'Windows': # Windows - procid = subprocess.Popen(('cmd','/c',f'notepad {docfile}')) - else: # linux variants - procid = subprocess.Popen(('xdg-open', docfile)) - procid.wait() - - def aboutBox(self): - tkMessageBox.showinfo('About', - 'UKMON user / camera maintenance tool.\nAll rights reserved, Mark McIntyre, 2024') - - -def log_timestamp(): - """ Returns timestamp for logging. - """ - return datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') - - -if __name__ == '__main__': - # Initialize main window - dir_ = os.getcwd() - - log.setLevel(logging.INFO) - - os.makedirs(os.path.join(dir_, 'logs'), exist_ok=True) - log_file = os.path.join(dir_, 'logs', log_timestamp() + '.log') - handler = logging.handlers.TimedRotatingFileHandler(log_file, when='D', interval=1) # Log to a different file each day - handler.setLevel(logging.INFO) - - formatter = logging.Formatter(fmt='%(asctime)s-%(levelname)s-%(module)s-line:%(lineno)d - %(message)s', datefmt='%Y/%m/%d %H:%M:%S') - handler.setFormatter(formatter) - log.addHandler(handler) - - ch = logging.StreamHandler(sys.stdout) - ch.setLevel(logging.DEBUG) - formatter = logging.Formatter(fmt='%(asctime)s-%(levelname)s: %(message)s', datefmt='%Y/%m/%d %H:%M:%S') - ch.setFormatter(formatter) - log.addHandler(ch) - - # Log program start - log.info("Program start") - - root = tk.Tk() - root.minsize(400, 100) - app = CamMaintenance(root, dir_) - root.iconbitmap(os.path.join(dir_,'camera.ico')) - root.protocol("WM_DELETE_WINDOW", app.on_closing) - app.mainloop() diff --git a/usermgmt/windows/stationmaint.ini.sample b/usermgmt/windows/stationmaint.ini.sample deleted file mode 100644 index 0a6b99346..000000000 --- a/usermgmt/windows/stationmaint.ini.sample +++ /dev/null @@ -1,11 +0,0 @@ -[helper] -USER= -SSHKEY= -HELPERIP=3.11.55.160 -REMOTEDIR=/home/ec2-user/prod/data -PLATEPARDIR= - -[store] -SRCBUCKET=ukmda-shared -LIVEBUCKET=ukmda-live -WEBSITEBUCKET=ukmda-website From 11b5b0bbe906eb22951a130571b58b3bf0dcd5ee Mon Sep 17 00:00:00 2001 From: Mark McIntyre Date: Wed, 12 Nov 2025 11:49:22 +0000 Subject: [PATCH 2/4] reorg server-side usermanagement code --- .../deployment/deploy-usermgmt.yml | 14 +--- .../usermgmt/addAdminUser.sh | 11 +-- .../usermgmt/addFBApiKey.sh | 13 ++-- .../usermgmt}/addSftpUser.sh | 0 .../usermgmt}/camTableMaintenance.py | 0 .../windows => archive/usermgmt}/testkeys.py | 0 .../usermgmt}/updateAwsKey.sh | 0 .../usermgmt}/updateKeysForSplit.py | 0 usermgmt/deploy-local.yml | 76 ------------------- usermgmt/dev-vars.yml | 11 --- usermgmt/prod-vars.yml | 9 --- usermgmt/server/ukmon.ini | 5 -- 12 files changed, 17 insertions(+), 122 deletions(-) rename usermgmt/deploy-server.yml => archive/deployment/deploy-usermgmt.yml (64%) rename usermgmt/addAdminUser.ps1 => archive/usermgmt/addAdminUser.sh (96%) rename usermgmt/addFBApiKey.ps1 => archive/usermgmt/addFBApiKey.sh (58%) rename {usermgmt/server => archive/usermgmt}/addSftpUser.sh (100%) rename {usermgmt/windows => archive/usermgmt}/camTableMaintenance.py (100%) rename {usermgmt/windows => archive/usermgmt}/testkeys.py (100%) rename {usermgmt/server => archive/usermgmt}/updateAwsKey.sh (100%) rename {usermgmt/server => archive/usermgmt}/updateKeysForSplit.py (100%) delete mode 100644 usermgmt/deploy-local.yml delete mode 100644 usermgmt/dev-vars.yml delete mode 100644 usermgmt/prod-vars.yml delete mode 100644 usermgmt/server/ukmon.ini diff --git a/usermgmt/deploy-server.yml b/archive/deployment/deploy-usermgmt.yml similarity index 64% rename from usermgmt/deploy-server.yml rename to archive/deployment/deploy-usermgmt.yml index db5f0ef94..8429780d3 100644 --- a/usermgmt/deploy-server.yml +++ b/archive/deployment/deploy-usermgmt.yml @@ -3,22 +3,16 @@ vars_files: - /mnt/c/Users/{{ lookup('env','USER' )}}/apikeys/mqvariables.enc vars: - srcdir: "/mnt/c/Users/{{ lookup('env','USER' )}}/OneDrive/dev/ukmda-dataprocessing/usermgmt/server" + srcdir: "/mnt/c/Users/{{ lookup('env','USER' )}}/OneDrive/dev/meteorhunting/ukmda-dataprocessing/archive/usermgmt" + destdir: /home/ec2-user/keymgmt tasks: - - name: import dev variables - include_vars: ./dev-vars.yml - tags: dev - - name: import prod variables - include_vars: ./prod-vars.yml - tags: prod - name: Ensures {{destdir}} exists file: path={{destdir}} state=directory - tags: [dev,prod] - name: Copy files - tags: [dev,prod] copy: src={{ item.src }} dest={{ item.dest }} mode={{ item.mode }} with_items: - {src: '{{srcdir}}/addSftpUser.sh', dest: '{{destdir}}/', mode: '744', backup: yes, directory_mode: no } - {src: '{{srcdir}}/updateAwsKey.sh', dest: '{{destdir}}/', mode: '744', backup: yes, directory_mode: no } - - {src: '{{srcdir}}/ukmon.ini', dest: '{{destdir}}/', mode: '644', backup: yes, directory_mode: no } + - {src: '{{srcdir}}/addAdminUser.sh', dest: '{{destdir}}/', mode: '744', backup: yes, directory_mode: no } + - {src: '{{srcdir}}/addFBApiKey.sh', dest: '{{destdir}}/', mode: '744', backup: yes, directory_mode: no } diff --git a/usermgmt/addAdminUser.ps1 b/archive/usermgmt/addAdminUser.sh similarity index 96% rename from usermgmt/addAdminUser.ps1 rename to archive/usermgmt/addAdminUser.sh index 3158fb995..eb02ec10c 100644 --- a/usermgmt/addAdminUser.ps1 +++ b/archive/usermgmt/addAdminUser.sh @@ -1,5 +1,6 @@ -# simple script to create a new user-maintenance account -# and save the access key locally. Can only be used by an account with IAM Admin permissions. -$username = $args[0] -aws iam create-user --user-name $username --profile ukmda_admin -aws iam add-user-to-group --user-name $username --user-group Administrators --profile ukmda_admin +#!/bin/bash +# simple script to create a new user-maintenance account +# and save the access key locally. Can only be used by an account with IAM Admin permissions. +$username = $args[0] +aws iam create-user --user-name $username --profile ukmda_admin +aws iam add-user-to-group --user-name $username --user-group Administrators --profile ukmda_admin diff --git a/usermgmt/addFBApiKey.ps1 b/archive/usermgmt/addFBApiKey.sh similarity index 58% rename from usermgmt/addFBApiKey.ps1 rename to archive/usermgmt/addFBApiKey.sh index c903c2eb4..eb957f893 100644 --- a/usermgmt/addFBApiKey.ps1 +++ b/archive/usermgmt/addFBApiKey.sh @@ -1,6 +1,7 @@ -# copyright Mark McIntyre, 2024- - -# script to add an api key for a user - -$username = $args[0] -aws apigateway create-api-key --name "$username" --region eu-west-1 --profile ukmda_admin --description "key for $username" --enabled \ No newline at end of file +#!/bin/bash +# copyright Mark McIntyre, 2024- + +# script to add an api key for a user + +$username = $1 +aws apigateway create-api-key --name "$username" --region eu-west-1 --description "key for $username" --enabled --profile ukmda_admin \ No newline at end of file diff --git a/usermgmt/server/addSftpUser.sh b/archive/usermgmt/addSftpUser.sh similarity index 100% rename from usermgmt/server/addSftpUser.sh rename to archive/usermgmt/addSftpUser.sh diff --git a/usermgmt/windows/camTableMaintenance.py b/archive/usermgmt/camTableMaintenance.py similarity index 100% rename from usermgmt/windows/camTableMaintenance.py rename to archive/usermgmt/camTableMaintenance.py diff --git a/usermgmt/windows/testkeys.py b/archive/usermgmt/testkeys.py similarity index 100% rename from usermgmt/windows/testkeys.py rename to archive/usermgmt/testkeys.py diff --git a/usermgmt/server/updateAwsKey.sh b/archive/usermgmt/updateAwsKey.sh similarity index 100% rename from usermgmt/server/updateAwsKey.sh rename to archive/usermgmt/updateAwsKey.sh diff --git a/usermgmt/server/updateKeysForSplit.py b/archive/usermgmt/updateKeysForSplit.py similarity index 100% rename from usermgmt/server/updateKeysForSplit.py rename to archive/usermgmt/updateKeysForSplit.py diff --git a/usermgmt/deploy-local.yml b/usermgmt/deploy-local.yml deleted file mode 100644 index 76677f582..000000000 --- a/usermgmt/deploy-local.yml +++ /dev/null @@ -1,76 +0,0 @@ ---- -- hosts: ukmonhelper2 - gather_facts: no - tasks: - - name: import dev variables - include_vars: ./dev-vars.yml - tags: dev - - name: import prod variables - include_vars: ./prod-vars.yml - tags: prod - - name: Ensures {{windestdir}} exists - file: path={{windestdir}} state=directory - delegate_to: localhost - tags: [dev,prod] - - name: Ensures {{windestdir}}/caminfo exists - file: path={{windestdir}}/caminfo state=directory - delegate_to: localhost - tags: [dev,prod] - - - name: build exe - ansible.builtin.shell: | - conda activate ukmon-admin - cd windows - pyinstaller ./stationMaint2.py --noconsole --onefile --windowed --icon .\camera.ico - args: - executable: pwsh.exe - delegate_to: localhost - register: outdata - tags: [dev,prod] - - - name: print debug - debug: - var: outdata.stdout - - name: Copy files - copy: src={{ item.src }} dest={{ item.dest }} mode={{ item.mode }} - delegate_to: localhost - tags: [dev,prod] - with_items: - - {src: '{{winsrcdir}}/dist/StationMaint2.exe', dest: '{{windestdir}}/', mode: '754', backup: yes, directory_mode: no } - - {src: '{{winsrcdir}}/camera.ico', dest: '{{windestdir}}/', mode: '644', backup: yes, directory_mode: no } - - {src: '{{winsrcdir}}/stationmaint.cfg', dest: '{{windestdir}}/', mode: '644', backup: yes, directory_mode: no } - - name: update source bucket - lineinfile: - path: '{{windestdir}}/stationmaint.cfg' - regexp: 'SRCBUCKET=' - line: 'SRCBUCKET={{srcbucket}}' - delegate_to: localhost - tags: [dev,prod] - - name: update live bucket - lineinfile: - path: '{{windestdir}}/stationmaint.cfg' - regexp: 'LIVEBUCKET=' - line: 'LIVEBUCKET={{livebucket}}' - delegate_to: localhost - tags: [dev,prod] - - name: update web bucket - lineinfile: - path: '{{windestdir}}/stationmaint.cfg' - regexp: 'WEBSITEBUCKET=' - line: 'WEBSITEBUCKET={{websitebucket}}' - delegate_to: localhost - tags: [dev,prod] - - name: update helper ipaddress - lineinfile: - path: '{{windestdir}}/stationmaint.cfg' - regexp: 'HELPERIP=' - line: 'HELPERIP={{serverip}}' - delegate_to: localhost - tags: [dev,prod] - - name: update helper remotedir - lineinfile: - path: '{{windestdir}}/stationmaint.cfg' - regexp: 'REMOTEDIR=' - line: 'REMOTEDIR={{remotedir}}' - delegate_to: localhost - tags: [dev,prod] diff --git a/usermgmt/dev-vars.yml b/usermgmt/dev-vars.yml deleted file mode 100644 index a459a2431..000000000 --- a/usermgmt/dev-vars.yml +++ /dev/null @@ -1,11 +0,0 @@ - winsrcdir: /mnt/c/Users/{{ lookup('env','USER' )}}/OneDrive/dev/ukmda-dataprocessing/usermgmt/windows - windestdir: /mnt/c/Users/{{ lookup('env','USER' )}}/OneDrive/dev/testing/usermgmt - destdir: /home/ec2-user/dev/keymgmt - srcbucket: mjmm-ukmon-shared - livebucket: mjmm-ukmon-live - websitebucket: mjmm-ukmonarchive.co.uk - liveprofile: default - archprofile: ukmda_admin - helper: ukmonhelper2 - serverip: "3.11.55.160" - remotedir: /home/ec2-user/dev/data \ No newline at end of file diff --git a/usermgmt/prod-vars.yml b/usermgmt/prod-vars.yml deleted file mode 100644 index bd561ec78..000000000 --- a/usermgmt/prod-vars.yml +++ /dev/null @@ -1,9 +0,0 @@ - winsrcdir: /mnt/c/Users/{{ lookup('env','USER' )}}/OneDrive/dev/ukmda-dataprocessing/usermgmt/windows - windestdir: /mnt/f/videos/MeteorCam/usermgmt - destdir: /home/ec2-user/keymgmt - srcbucket: ukmda-shared - livebucket: ukmda-live - websitebucket: ukmda-website - helper: ukmonhelper2 - serverip: "3.11.55.160" - remotedir: /home/ec2-user/prod/data \ No newline at end of file diff --git a/usermgmt/server/ukmon.ini b/usermgmt/server/ukmon.ini deleted file mode 100644 index 59c2a1061..000000000 --- a/usermgmt/server/ukmon.ini +++ /dev/null @@ -1,5 +0,0 @@ -# config data for this station -export LOCATION=STATIONLOCATION -export UKMONHELPER=3.11.55.160 -export UKMONKEY=~/.ssh/ukmon -export RMSCFG=~/source/RMS/.config From d7ead72d6d77374bc2d90ddefa54a1cd629829ad Mon Sep 17 00:00:00 2001 From: Mark McIntyre Date: Mon, 8 Dec 2025 21:32:25 +0000 Subject: [PATCH 3/4] bugfix in addSftpUser.sh to match by word in the call to grep --- archive/usermgmt/addSftpUser.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/archive/usermgmt/addSftpUser.sh b/archive/usermgmt/addSftpUser.sh index 8ddb730cd..82fdcbd26 100644 --- a/archive/usermgmt/addSftpUser.sh +++ b/archive/usermgmt/addSftpUser.sh @@ -44,7 +44,7 @@ if [ ! -f $keydir/keys/$shortid_l.key ] ; then fi # add a unix user and set their homedir to /var/sftp/userid -grep $userid /etc/passwd +grep -w $userid /etc/passwd if [ $? -eq 1 ] ; then dt=$(date +%Y-%m-%d) logger -s -t addSftpUser "Creating unix user $userid" @@ -91,6 +91,10 @@ if [ ! -z $oldloc ] ; then logger -s -t addSftpUser "Moving $oldloc to $userid" sudo cp /var/sftp/$oldloc/ukmon.ini /var/sftp/$oldloc/ukmon.ini.bkp sudo cp $keydir/inifs/$userid.ini /var/sftp/$oldloc/ukmon.ini + # and copy the ssh authorized keys file + sudo cp $oldloc/.ssh/authorized_keys /var/sftp/$userid/.ssh/ + sudo chown -R $userid:$userid /var/sftp/$userid/.ssh/authorized_keys + sudo chmod 644 /var/sftp/$userid/.ssh/authorized_keys fi logger -s -t addSftpUser "Finished" From a995ce62878a8a72e6d5f079470afc2b04301a69 Mon Sep 17 00:00:00 2001 From: Mark McIntyre Date: Wed, 10 Dec 2025 11:54:37 +0000 Subject: [PATCH 4/4] bugfix: don't link to nonexistent images in the search dialog --- .../reports/createSearchableFormat.py | 62 +++++++++---------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/archive/ukmon_pylib/reports/createSearchableFormat.py b/archive/ukmon_pylib/reports/createSearchableFormat.py index 2e47d56f7..e2df79c2b 100644 --- a/archive/ukmon_pylib/reports/createSearchableFormat.py +++ b/archive/ukmon_pylib/reports/createSearchableFormat.py @@ -9,10 +9,27 @@ import os import pandas as pd import datetime +import boto3 + + +def checkUrl(s3, url): + siteroot = os.getenv('WEBSITEBUCKET', default='s3://ukmda-website') + retval = url + tmpurl = url + if url[0]==',': + tmpurl = url[1:] + try: + _ = s3.head_object(Bucket=siteroot[5:], Key=tmpurl[1:]) + except Exception: + #print(e) + retval = '/img/missing-white.png' + print(url, retval) + return retval def convertSingletoSrchable(datadir, year, newonly=True): print(datetime.datetime.now(), 'single-detection searchable index start') + s3 = boto3.client('s3') # load the single-station combined data if newonly is False: @@ -34,6 +51,10 @@ def convertSingletoSrchable(datadir, year, newonly=True): uadata['fn']=[f'/img/single/{y}/{y}{m:02d}/'+f.replace('.fits','.jpg') for f,y,m in zip(uadata.Filename, uadata.Y, uadata.M)] + print(datetime.datetime.now(), 'checking target urls exist') + uadata['targfn'] = [checkUrl(s3, x) for x in uadata.fn] + print(datetime.datetime.now(), 'done') + # create array for source print(datetime.datetime.now(), 'add source column') srcs = ['2Single']*len(uadata.Filename) @@ -44,41 +65,13 @@ def convertSingletoSrchable(datadir, year, newonly=True): print(datetime.datetime.now(), 'create interim dataframe') hdr=['eventtime','source','shower','Mag','loccam','url','imgs', 'loctime', 'Y','M'] resdf = pd.DataFrame(zip(uadata.Dtstamp, srcs, uadata.Shwr, - uadata.Mag, uadata.ID, uadata.fn, uadata.fn, uadata.LocalTime, + uadata.Mag, uadata.ID, uadata.targfn, uadata.targfn, uadata.LocalTime, uadata.Y, uadata.M), columns=hdr) # fix up some mangled historical data resdf.loc[resdf.loccam=='Ringwood_N_UK000S', 'loccam'] = 'UK000S' resdf.loc[resdf.loccam=='Tackley_SW_UK0006', 'loccam'] = 'UK0006' - # select the RMS data out, its good now - # FIXME - needs to select for "not FF_UK9" so we can include non-UK cameras - rmsdata=resdf[resdf.url.str.contains('FF_UK0')] - rmsdata = rmsdata.drop(columns=['Y','M','loctime']) - - # now select out the UFO dta and fix it up - ufodata=resdf[resdf.url.str.contains('FF_UK9')] - #fix up Clanfield cameras - ufodata.loc[ufodata.loccam=='UK9990', 'loccam'] = 'Clanfield_NE' - ufodata.loc[ufodata.loccam=='UK9989', 'loccam'] = 'Clanfield_NW' - ufodata.loc[ufodata.loccam=='UK9988', 'loccam'] = 'Clanfield_SE' - ufodata = ufodata.drop(columns=['url','imgs']) - - # create the URL and imgs fields - ufodata['url']=[f'/img/single/{y}/{y}{m:02d}/M{lt}_{f}P.jpg' - for f,y,m,lt in zip(ufodata.loccam, ufodata.Y, ufodata.M, ufodata.loctime)] - ufodata['imgs'] = ufodata.url - ufodata = ufodata.drop(columns=['loctime','Y','M']) - - # annoying special case for UK0001, H and S which do not upload JPGs - rmsdata.loc[rmsdata.loccam=='UK0001','url']='/img/missing-white.png' - rmsdata.loc[rmsdata.loccam=='UK0001','imgs']='/img/missing-white.png' - rmsdata.loc[rmsdata.loccam=='UK000H','url']='/img/missing-white.png' - rmsdata.loc[rmsdata.loccam=='UK000H','imgs']='/img/missing-white.png' - rmsdata.loc[rmsdata.loccam=='UK000S','url']='/img/missing-white.png' - rmsdata.loc[rmsdata.loccam=='UK000S','imgs']='/img/missing-white.png' - - resdf = pd.concat([rmsdata,ufodata]) if newonly is True: return resdf, rmsuafile else: @@ -114,19 +107,22 @@ def convertMatchToSrchable(datadir, year, newonly=True): if __name__ == '__main__': if len(sys.argv) < 3: - print('usage: python createSearchableFormat.py year dest mode') + print('usage: python createSearchableFormat.py year mode outdir') exit(1) else: datadir = os.getenv('DATADIR', default='/home/ec2-user/prod/data') year = sys.argv[1] mode = sys.argv[2] + outdir = os.path.join(datadir, 'searchidx') + if len(sys.argv) > 3: + outdir = sys.argv[3] # create a set of single-station data and merge with last match set if mode == 'singles': print(datetime.datetime.now(), 'converting single-station data') newsingles, fname = convertSingletoSrchable(datadir, year, True) - outfile = os.path.join(datadir, 'searchidx', '{:s}-singles-new.csv'.format(year)) + outfile = os.path.join(outdir, '{:s}-singles-new.csv'.format(year)) if newsingles is not None: newsingles.to_csv(outfile, index=False, header=False) if fname is not None: @@ -136,11 +132,11 @@ def convertMatchToSrchable(datadir, year, newonly=True): elif mode == 'matches': print(datetime.datetime.now(), 'converting match data') newmatches, fname = convertMatchToSrchable(datadir, year, True) - outfile = os.path.join(datadir, 'searchidx', '{:s}-matches-new.csv'.format(year)) + outfile = os.path.join(outdir, '{:s}-matches-new.csv'.format(year)) if newmatches is not None: newmatches.to_csv(outfile, index=False, header=False) if fname is not None: os.remove(fname) else: - print('usage: createSearchableFormat yyyy matches_or_singles') + print('usage: createSearchableFormat year mode outdir')